diff --git a/README.md b/README.md index a696330..61142b1 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ override fun onNewIntent(intent: Intent) { ```typescript import LinkForty from '@linkforty/mobile-sdk-react-native'; -// 1. Initialize the SDK (call once at app startup, and await it before step 2) +// 1. Initialize the SDK (call once at app startup; steps 2 and 3 may follow without awaiting it) await LinkForty.init({ baseUrl: 'https://api.linkforty.com', // hosted service; or your own server's URL if you self-host appToken: 'at_a1b2c3d4...', // Required on the hosted service @@ -170,7 +170,7 @@ LinkForty.onDeferredDeepLink((data) => { }); ``` -Await `init()` before calling `onDeepLink()`: `onDeepLink()` throws if `init()` was never called, and one registered while `init()` is still running recognizes links on the `baseUrl` host only, not your workspace's other link hosts. +`onDeepLink()` and `onDeferredDeepLink()` may be called as soon as `init()` has been called, without awaiting it, so an app can register its link handler on the first frame while the install report is still in flight. `onDeepLink()` throws only if `init()` was never called. A link handled before the SDK has learned your workspace's link hosts waits for them, up to 3 seconds (not on later launches, which know them from the start). ## API Reference @@ -180,6 +180,8 @@ Initialize the SDK. Must be called before any other method. Calling it a second On the first launch it reports the install (and on Android waits up to 2 seconds for the Google Play install referrer) before it resolves. If the report fails (no connection, a server error), it is reported again on the next launch, and so on until the server accepts it; until then there is no install ID and `trackEvent()` sends nothing. +`init()` always settles. Every request the SDK makes is abandoned after `requestTimeoutMs` (default 10 seconds), since React Native sets no network timeout of its own on Android, and the device's user agent lookup is abandoned after 3 seconds in favor of a generated one. A network failure, a timeout or unavailable storage (the AsyncStorage native module not linked) is logged, never thrown: with unavailable storage the SDK behaves as on a first launch and keeps working for the session. The only error `init()` rejects with is one thrown by your own `onDeferredDeepLink` callback. + **Upgrading from 1.9.0 or earlier:** those versions never retried a failed first-launch install report, which left the device without an install ID, so its events were dropped. After the upgrade, such a device reports its install once. It appears as an install dated at the upgrade, usually organic, because the click that led to the original install is normally outside the attribution window by then. Devices that already have an install ID never report again. This recovery report only restores the install ID. It does not call `onDeferredDeepLink` (the user has been using the app, so routing them like a fresh install would be wrong). Any link it matches is not saved for `getInstallData()` and is not credited for later events: the match is made at upgrade time, not from the click that led to the install. A link the user taps to open the app is credited as usual. ```typescript @@ -189,6 +191,7 @@ await LinkForty.init({ apiKey: 'your-api-key', // Optional (only needed for createLink) debug: true, // Optional (default: false) attributionWindow: 7, // Optional, in days (default: 7) + requestTimeoutMs: 10000, // Optional, in ms (default: 10000) }); ``` @@ -199,6 +202,7 @@ await LinkForty.init({ | `apiKey` | `string` | No | API key, only needed for `createLink()`. It is a secret: don't ship one in your app unless you create links from it. | | `debug` | `boolean` | No | Log what the SDK does, including link URLs and link data, to the console (default: `false`). The API key is never logged. Without `debug` the SDK logs only failures: a failed install report or event, `init()` called twice, and an event dropped for lack of an install ID. | | `attributionWindow` | `number` | No | Attribution window in days (default: 7) | +| `requestTimeoutMs` | `number` | No | How long any request to the server may take before it is abandoned, in milliseconds (default: 10000). An abandoned install report is retried on the next launch. | | `autoTrackNavigation` | `boolean \| { captureParams?: string[]; debounceMs?: number }` | No | Emit `screen_view` events from React Navigation. See [Automatic Screen Tracking](#automatic-screen-tracking-autotracknavigation). Default: off | | `navigationRef` | `NavigationContainerRefLike` | With `autoTrackNavigation` | Your React Navigation container ref | @@ -234,19 +238,21 @@ LinkForty.onDeepLink((url: string, data: DeepLinkData | null) => { ### `onDeferredDeepLink(callback)` -Listen for deferred deep links. Called once, on the launch that reports the install: with the link's data if the user clicked a LinkForty link before installing, or `null` for an organic install. A launch whose report fails also calls it with `null`, and the launch that later reports the install successfully calls it again with that report's result. The one-time report from a device upgraded from 1.9.0 or earlier (see [`init`](#initconfig)) does not call it. It can be registered before or after `init()`. Later launches don't call it; use `getInstallData()` for the install's link data. +Listen for deferred deep links. Called once, on the launch that reports the install: with the link's data if the user clicked a LinkForty link before installing, or `null` for an organic install. A launch whose report fails also calls it with `null`, and the launch that later reports the install successfully calls it again with that report's result. The second argument says which of the three happened. The one-time report from a device upgraded from 1.9.0 or earlier (see [`init`](#initconfig)) does not call it. It can be registered before or after `init()`. Later launches don't call it; use `getInstallData()` for the install's link data. ```typescript -import type { DeepLinkData } from '@linkforty/mobile-sdk-react-native'; +import type { DeepLinkData, DeferredDeepLinkResult } from '@linkforty/mobile-sdk-react-native'; -LinkForty.onDeferredDeepLink((data: DeepLinkData | null) => { +LinkForty.onDeferredDeepLink((data: DeepLinkData | null, result: DeferredDeepLinkResult) => { if (data) { // Attributed install — user clicked a link before installing console.log('Came from:', data.utmParameters?.source); const { route, id } = data.customParameters || {}; if (route) navigation.navigate(route, { id }); + } else if (result.status === 'failed') { + // The report didn't reach the server; it is sent again on the next launch + console.warn('Install report failed:', result.error); } else { - // Organic install or attribution failed console.log('Organic install'); } }); @@ -254,7 +260,8 @@ LinkForty.onDeferredDeepLink((data: DeepLinkData | null) => { | Callback Parameter | Type | Description | |--------------------|------|-------------| -| `data` | `DeepLinkData \| null` | Attributed link data, or `null` for organic installs | +| `data` | `DeepLinkData \| null` | Attributed link data, or `null` for an organic install or a failed report | +| `result` | `DeferredDeepLinkResult` | `{ status: 'attributed' \| 'organic' \| 'failed', error? }`. With `failed`, `error` is the failure (an `Error` with `status` for an HTTP error response, `code: 'timeout'` for an abandoned request) and the next launch reports again. A callback that takes only `data` keeps working. | The deferred link also carries how certain the match is: `confidenceScore`, `matchedFactors`, `attributionMethod` and `competingLinks` (see [`DeepLinkData`](#deeplinkdata)). Before acting on a sensitive link, check `competingLinks`: @@ -327,7 +334,7 @@ LinkForty.getExternalUserId(); // 'user_123' Track an in-app event for attribution analytics. Requires a successful install report (automatic on first launch, and retried on later launches if it fails); without an install ID the call logs a warning and sends nothing. -Events are sent once, straight away. There is no offline queue: an event that fails to send (no connection, server error) is logged and dropped, not retried. +Events are sent once, straight away. An event tracked while `init()` is still running is held (up to 100) and sent, with the time it was tracked, once `init()` has the install ID; if that launch's install report fails, the held events are dropped with one warning. There is no offline queue: an event that fails to send (no connection, server error) is logged and dropped, not retried. ```typescript await LinkForty.trackEvent('purchase', { @@ -491,6 +498,7 @@ interface LinkFortyConfig { appToken?: string; debug?: boolean; attributionWindow?: number; + requestTimeoutMs?: number; // default 10000 // true = screen names only; pass options to opt into specific params autoTrackNavigation?: boolean | { captureParams?: string[]; debounceMs?: number }; navigationRef?: NavigationContainerRefLike; // required when autoTrackNavigation is enabled diff --git a/__tests__/DeepLinkHandler.test.ts b/__tests__/DeepLinkHandler.test.ts index 8d57f6a..017b1fe 100644 --- a/__tests__/DeepLinkHandler.test.ts +++ b/__tests__/DeepLinkHandler.test.ts @@ -60,6 +60,28 @@ describe('DeepLinkHandler listener', () => { expect(second).toHaveBeenCalledWith(OTHER_URL, null); }); + it('delivers the launch URL once, however often it is initialized', async () => { + const LAUNCH_URL = 'myapp://from-launch'; + linking.getInitialURL.mockClear(); + linking.getInitialURL.mockResolvedValue(LAUNCH_URL); + try { + const handler = new DeepLinkHandler(); + const first = vi.fn(); + const second = vi.fn(); + handler.initialize(BASE_URL, first); + await flush(); + handler.initialize(BASE_URL, second); + await flush(); + + expect(linking.getInitialURL).toHaveBeenCalledTimes(1); + expect(first).toHaveBeenCalledWith(LAUNCH_URL, null); + expect(second).not.toHaveBeenCalled(); + } finally { + linking.getInitialURL.mockResolvedValue(null); + linking.getInitialURL.mockClear(); + } + }); + it('cleanup() removes only its own listener, not the app\'s', async () => { const appListener = vi.fn(); linking.addEventListener('url', appListener); @@ -111,4 +133,75 @@ describe('LinkFortySDK.onDeepLink', () => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + + it('registered right after init() without awaiting it, keeps one listener and learns the link hosts', async () => { + const LINK_HOST_URL = 'https://acme.toapp.me/abc123'; + const resolved = { shortCode: 'abc123', linkId: 'link-1', customParameters: { route: 'promo' } }; + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/api/sdk/v1/link-domains')) { + return { ok: true, status: 200, json: async () => ({ domains: ['acme.toapp.me'] }) }; + } + if (url.includes('/api/sdk/v1/resolve/')) { + return { ok: true, status: 200, json: async () => resolved }; + } + return { ok: true, status: 200, json: async () => ({ installId: 'i-1', attributed: false }) }; + }); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const sdk = new LinkFortySDK(); + const callback = vi.fn(); + const init = sdk.init({ baseUrl: BASE_URL, appToken: 'at_00000000000000000000000000000000' }); + sdk.onDeepLink(callback); + await init; + await flush(); + + expect(linking.count()).toBe(1); + + linking.emit(LINK_HOST_URL); + await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); + expect(callback).toHaveBeenCalledWith(LINK_HOST_URL, expect.objectContaining(resolved)); + + // Registering again after init() still replaces the callback, with one listener + const replacement = vi.fn(); + sdk.onDeepLink(replacement); + await flush(); + expect(linking.count()).toBe(1); + linking.emit(OTHER_URL); + await flush(); + expect(replacement).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); + + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('a second init() while the first runs keeps one listener and one delivery per link', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ installId: 'i-1', attributed: false }) }))); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + // What React 18 StrictMode does to an effect in development: run it twice + const sdk = new LinkFortySDK(); + const first = vi.fn(); + const second = vi.fn(); + const init1 = sdk.init({ baseUrl: BASE_URL }); + sdk.onDeepLink(first); + const init2 = sdk.init({ baseUrl: BASE_URL }); + sdk.onDeepLink(second); + expect(init2).toBe(init1); + await init2; + await flush(); + + expect(linking.count()).toBe(1); + expect(console.warn).toHaveBeenCalledWith('LinkForty SDK already initialized'); + + linking.emit(OTHER_URL); + await flush(); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); }); diff --git a/__tests__/DeferredDeepLink.test.ts b/__tests__/DeferredDeepLink.test.ts index dbe66a1..d7fca74 100644 --- a/__tests__/DeferredDeepLink.test.ts +++ b/__tests__/DeferredDeepLink.test.ts @@ -3,34 +3,46 @@ import { DeferredDeepLink, deferredDeepLinkFromInstall } from '../src/DeferredDe import type { DeepLinkData, InstallAttributionResponse } from '../src/types'; const data = { shortCode: 'abc123', linkId: 'link-1' } as DeepLinkData; +const attributed = { status: 'attributed' } as const; +const organic = { status: 'organic' } as const; describe('DeferredDeepLink', () => { it('calls a callback registered before the install is reported', () => { const deferred = new DeferredDeepLink(); const callback = vi.fn(); deferred.register(callback); - deferred.deliver(data); + deferred.deliver(data, attributed); expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith(data); + expect(callback).toHaveBeenCalledWith(data, attributed); }); it('calls a callback registered after the install is reported', () => { const deferred = new DeferredDeepLink(); - deferred.deliver(data); + deferred.deliver(data, attributed); const callback = vi.fn(); deferred.register(callback); - expect(callback).toHaveBeenCalledWith(data); + expect(callback).toHaveBeenCalledWith(data, attributed); }); it('delivers null for an organic install registered after init', () => { const deferred = new DeferredDeepLink(); - deferred.deliver(null); + deferred.deliver(null, organic); const callback = vi.fn(); deferred.register(callback); - expect(callback).toHaveBeenCalledWith(null); + expect(callback).toHaveBeenCalledWith(null, organic); + }); + + it('tells a failed report apart from an organic install', () => { + const deferred = new DeferredDeepLink(); + const callback = vi.fn(); + deferred.register(callback); + const error = new Error('HTTP 503'); + deferred.deliver(null, { status: 'failed', error }); + + expect(callback).toHaveBeenCalledWith(null, { status: 'failed', error }); }); it('calls nothing on a launch that does not report the install', () => { diff --git a/__tests__/FingerprintCollector.test.ts b/__tests__/FingerprintCollector.test.ts new file mode 100644 index 0000000..6a45fdc --- /dev/null +++ b/__tests__/FingerprintCollector.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +const deviceInfo = vi.hoisted(() => ({ + getUserAgent: vi.fn(async () => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15'), + getModel: vi.fn(async () => 'iPhone'), + getSystemVersion: vi.fn(async () => '18.0'), + getVersion: vi.fn(async () => '2.3.1'), + getApplicationName: vi.fn(() => 'Acme'), +})); + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios', select: (o: Record) => o.ios }, + Dimensions: { get: () => ({ width: 390, height: 844 }) }, + NativeModules: {}, +})); + +vi.mock('react-native-device-info', () => ({ default: deviceInfo })); + +import { FingerprintCollector, USER_AGENT_TIMEOUT_MS } from '../src/FingerprintCollector'; + +afterEach(() => { + vi.useRealTimers(); + deviceInfo.getUserAgent.mockReset(); + deviceInfo.getUserAgent.mockImplementation( + async () => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15', + ); +}); + +describe('FingerprintCollector user agent', () => { + it("uses the device's web user agent when the lookup answers", async () => { + const fingerprint = await FingerprintCollector.collect(); + + expect(fingerprint.userAgent).toContain('AppleWebKit'); + expect(fingerprint).toMatchObject({ osVersion: '18.0', appVersion: '2.3.1', deviceModel: 'iPhone' }); + }); + + it('generates one in the native SDK form when the lookup never settles', async () => { + vi.useFakeTimers(); + deviceInfo.getUserAgent.mockImplementation(() => new Promise(() => {})); + + const pending = FingerprintCollector.collect(); + await vi.advanceTimersByTimeAsync(USER_AGENT_TIMEOUT_MS); + const fingerprint = await pending; + + expect(fingerprint.userAgent).toBe('Acme/2.3.1 iOS/18.0'); + }); + + it('generates one when the lookup rejects', async () => { + deviceInfo.getUserAgent.mockImplementation(async () => { + throw new Error('getUserAgentError'); + }); + + const fingerprint = await FingerprintCollector.collect(); + + expect(fingerprint.userAgent).toBe('Acme/2.3.1 iOS/18.0'); + }); +}); diff --git a/__tests__/LinkFortySDK.test.ts b/__tests__/LinkFortySDK.test.ts index 55a1cc3..93ad0ae 100644 --- a/__tests__/LinkFortySDK.test.ts +++ b/__tests__/LinkFortySDK.test.ts @@ -131,7 +131,10 @@ describe('LinkFortySDK install report', () => { const first = vi.fn(); offline.onDeferredDeepLink(first); await offline.init(config); - expect(first).toHaveBeenCalledWith(null); + expect(first).toHaveBeenCalledWith(null, { + status: 'failed', + error: expect.objectContaining({ message: 'Network request failed' }), + }); fetchMock.mockImplementation(async () => jsonResponse(200, { @@ -147,7 +150,83 @@ describe('LinkFortySDK install report', () => { relaunch.onDeferredDeepLink(second); await relaunch.init(config); - expect(second).toHaveBeenCalledWith(expect.objectContaining({ shortCode: 'abc', linkId: 'link-1' })); + expect(second).toHaveBeenCalledWith( + expect.objectContaining({ shortCode: 'abc', linkId: 'link-1' }), + { status: 'attributed' }, + ); + }); + + it('reports an organic install as such', async () => { + const sdk = new LinkFortySDK(); + const callback = vi.fn(); + sdk.onDeferredDeepLink(callback); + await sdk.init(config); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(null, { status: 'organic' }); + }); + + it('delivers once even when the app callback throws, and init() rejects with that error', async () => { + const sdk = new LinkFortySDK(); + const callback = vi.fn(() => { + throw new Error('app bug'); + }); + sdk.onDeferredDeepLink(callback); + + await expect(sdk.init(config)).rejects.toThrow('app bug'); + expect(callback).toHaveBeenCalledTimes(1); + expect(logged()).not.toContain('Failed to report install'); + + // The SDK itself finished initializing before the callback ran + await sdk.trackEvent('after'); + expect(calls(fetchMock, '/api/sdk/v1/event')).toHaveLength(1); + }); + + it('does not make an awaited init() wait on the sends of held events', async () => { + let releaseEvent!: () => void; + fetchMock.mockImplementation((url: string) => + url.endsWith('/api/sdk/v1/event') + ? new Promise((resolve) => { + releaseEvent = () => resolve(jsonResponse(200, {})); + }) + : Promise.resolve(jsonResponse(200, organicInstall)), + ); + const sdk = new LinkFortySDK(); + const init = sdk.init(config); + void sdk.trackEvent('held'); + await init; + + expect(calls(fetchMock, '/api/sdk/v1/event')).toHaveLength(1); + releaseEvent(); + }); + + it('names the status when an error response has no JSON body', async () => { + fetchMock.mockImplementation(async () => ({ + ok: false, + status: 502, + json: async () => { + throw new SyntaxError('Unexpected token <'); + }, + }) as unknown as Response); + const sdk = new LinkFortySDK(); + const callback = vi.fn(); + sdk.onDeferredDeepLink(callback); + await sdk.init(config); + + const [[, result]] = callback.mock.calls; + expect(result.status).toBe('failed'); + expect(result.error.message).toBe('HTTP 502'); + expect(result.error.status).toBe(502); + }); + + it("names the status and the body's message on an error response", async () => { + fetchMock.mockImplementation(async () => jsonResponse(400, { error: 'Validation failed' })); + const sdk = new LinkFortySDK(); + const callback = vi.fn(); + sdk.onDeferredDeepLink(callback); + await sdk.init(config); + + expect(callback.mock.calls[0][1].error.message).toBe('HTTP 400: Validation failed'); }); it('does not report again after a 2xx response without an install id', async () => { @@ -159,6 +238,148 @@ describe('LinkFortySDK install report', () => { }); }); +describe('LinkFortySDK request deadline', () => { + /** A fetch that never answers until its signal aborts, as a half-open connection does. */ + function hangingFetch(): typeof fetch { + return vi.fn((_url: string, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + reject(error); + }); + }), + ) as unknown as typeof fetch; + } + + it('abandons an install report that never answers and reports the failure', async () => { + vi.useFakeTimers(); + try { + vi.stubGlobal('fetch', hangingFetch()); + const sdk = new LinkFortySDK(); + const callback = vi.fn(); + sdk.onDeferredDeepLink(callback); + + const init = sdk.init({ ...config, requestTimeoutMs: 1000 }); + await vi.advanceTimersByTimeAsync(999); + expect(callback).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await init; + + expect(callback).toHaveBeenCalledWith(null, { + status: 'failed', + error: expect.objectContaining({ + code: 'timeout', + message: 'Request timed out after 1000ms: POST /api/sdk/v1/install', + }), + }); + expect(await sdk.getInstallId()).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('waits up to 10 seconds by default', async () => { + vi.useFakeTimers(); + try { + vi.stubGlobal('fetch', hangingFetch()); + const sdk = new LinkFortySDK(); + const callback = vi.fn(); + sdk.onDeferredDeepLink(callback); + + const init = sdk.init(config); + await vi.advanceTimersByTimeAsync(9_999); + expect(callback).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await init; + + expect(callback.mock.calls[0][1].status).toBe('failed'); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('LinkFortySDK events tracked while init() runs', () => { + it('sends them, with the time they were tracked, once the install ID is known', async () => { + const sdk = new LinkFortySDK(); + const init = sdk.init(config); + const before = new Date().toISOString(); + await sdk.trackEvent('opened_from_cold_start', { screen: 'home' }); + expect(calls(fetchMock, '/api/sdk/v1/event')).toHaveLength(0); + expect(console.warn).not.toHaveBeenCalled(); + + await init; + + const events = calls(fetchMock, '/api/sdk/v1/event'); + expect(events).toHaveLength(1); + const body = JSON.parse(String(events[0][1]?.body)); + expect(body).toMatchObject({ + installId: INSTALL_ID, + eventName: 'opened_from_cold_start', + eventData: { screen: 'home' }, + }); + expect(body.timestamp >= before).toBe(true); + }); + + it('drops them with one warning when the report fails', async () => { + fetchMock.mockImplementation(networkDown); + const sdk = new LinkFortySDK(); + const init = sdk.init(config); + await sdk.trackEvent('first'); + await sdk.trackEvent('second'); + await init; + + expect(calls(fetchMock, '/api/sdk/v1/event')).toHaveLength(0); + expect(logged()).toContain('Cannot track 2 event(s)'); + }); + + it('still drops an event tracked after init() without an install ID', async () => { + fetchMock.mockImplementation(networkDown); + const sdk = new LinkFortySDK(); + await sdk.init(config); + await sdk.trackEvent('later'); + + expect(calls(fetchMock, '/api/sdk/v1/event')).toHaveLength(0); + expect(logged()).toContain('Cannot track event'); + }); +}); + +describe('LinkFortySDK without working storage', () => { + function breakStorage() { + const asyncStorage = (storage as unknown as { default: Record> }).default; + const failure = new TypeError("Cannot read property 'getItem' of null"); + asyncStorage.getItem.mockImplementation(() => Promise.reject(failure)); + asyncStorage.setItem.mockImplementation(() => Promise.reject(failure)); + return failure; + } + + afterEach(() => { + const asyncStorage = (storage as unknown as { default: Record> }).default; + asyncStorage.getItem.mockImplementation((key: string) => Promise.resolve(store[key] ?? null)); + asyncStorage.setItem.mockImplementation((key: string, value: string) => { + store[key] = value; + return Promise.resolve(); + }); + }); + + it('init() resolves, reports the install, delivers the deferred deep link and logs the cause once', async () => { + breakStorage(); + const sdk = new LinkFortySDK(); + const callback = vi.fn(); + sdk.onDeferredDeepLink(callback); + await sdk.init(config); + await sdk.trackEvent('signup'); + + expect(calls(fetchMock, '/api/sdk/v1/install')).toHaveLength(1); + expect(callback).toHaveBeenCalledWith(null, { status: 'organic' }); + expect(calls(fetchMock, '/api/sdk/v1/event')).toHaveLength(1); + const errors = (console.error as unknown as ReturnType).mock.calls; + expect(errors).toHaveLength(1); + expect(String(errors[0][0])).toContain('Storage is unavailable'); + }); +}); + describe('LinkFortySDK recovery of a device whose earlier report failed', () => { // What versions up to 1.9.0 left behind when the first-launch report failed: // the first-launch flag set, but no install ID diff --git a/llms.txt b/llms.txt index 150346d..8f3eee7 100644 --- a/llms.txt +++ b/llms.txt @@ -48,6 +48,8 @@ interface LinkFortyConfig { debug?: boolean; /** Attribution window in days (default: 7) */ attributionWindow?: number; + /** How long any request may take before it is abandoned, in ms (default: 10000) */ + requestTimeoutMs?: number; /** Emit screen_view events from React Navigation (opt-in); requires navigationRef */ autoTrackNavigation?: boolean | { captureParams?: string[]; debounceMs?: number }; /** React Navigation container ref (createNavigationContainerRef()) */ @@ -135,7 +137,9 @@ import LinkFortySDK from '@linkforty/mobile-sdk-react-native'; ### init(config: LinkFortyConfig): Promise\ -Initialize the SDK. Must be called before any other method. Call this early in your app lifecycle (App.tsx or index.js), and await it before calling onDeepLink(): onDeepLink() throws if init() was never called, and one registered while init() is still running only recognizes links on the baseUrl host. Calling init() twice logs a warning and does nothing. +Initialize the SDK. Must be called before any other method. Call this early in your app lifecycle (App.tsx or index.js). onDeepLink() and onDeferredDeepLink() may be called right after it without awaiting it; onDeepLink() throws only if init() was never called. A link handled before the SDK has learned the workspace's link hosts waits for them, up to 3 seconds. Calling init() twice logs a warning and does nothing. + +init() always settles: every request is abandoned after requestTimeoutMs (default 10 seconds; React Native sets no network timeout on Android), the user agent lookup after 3 seconds (a generated one is sent instead), and a network failure, timeout or unavailable storage is logged, never thrown. It rejects only with an error thrown by the app's own onDeferredDeepLink callback. ```typescript await LinkFortySDK.init({ @@ -144,6 +148,7 @@ await LinkFortySDK.init({ apiKey: 'lf_live_abc123', // optional — only for createLink(); a secret debug: __DEV__, attributionWindow: 7, // days + requestTimeoutMs: 10000, // ms; default }); ``` @@ -164,23 +169,25 @@ LinkFortySDK.onDeepLink((url, data) => { ### onDeferredDeepLink(callback: DeferredDeepLinkCallback): void -Register a callback for deferred deep links — fires once, on the launch that reports the install (the first launch, or a later one if that report failed): with the link's data if the install was attributed to a link click, or null for an organic install. A launch whose report fails also calls it with null. This enables routing users to specific content after they install. +Register a callback for deferred deep links — fires once, on the launch that reports the install (the first launch, or a later one if that report failed): with the link's data if the install was attributed to a link click, or null for an organic install. A launch whose report fails also calls it with null. The second argument, `result: { status: 'attributed' | 'organic' | 'failed', error? }`, says which; with `failed`, `error` is the failure (an Error with `status` for an HTTP error response, `code: 'timeout'` for an abandoned request) and the next launch reports again. A callback that takes only `data` keeps working. This enables routing users to specific content after they install. ```typescript -LinkFortySDK.onDeferredDeepLink((data) => { +LinkFortySDK.onDeferredDeepLink((data, result) => { if (data) { console.log('Deferred deep link:', data.shortCode); console.log('UTM source:', data.utmParameters?.source); if (data.customParameters?.route === 'product') { navigation.navigate('Product', { id: data.customParameters.productId }); } + } else if (result.status === 'failed') { + console.warn('Install report failed, retried on the next launch:', result.error); } }); ``` ### trackEvent(name: string, properties?: Record\): Promise\ -Track in-app events for attribution and conversion analysis. Needs the install ID from the install report (sent on first launch, and again on each later launch until the server accepts it); without it the call logs a warning and sends nothing. Devices whose first-launch report failed on SDK 1.9.0 or earlier (no install ID) report the install once after upgrading; it appears as an install dated at the upgrade, usually organic, and only restores the install ID: it does not call onDeferredDeepLink, save link data for getInstallData(), or credit a link for later events. There is no offline queue: a failed send is logged and dropped, not retried. +Track in-app events for attribution and conversion analysis. Needs the install ID from the install report (sent on first launch, and again on each later launch until the server accepts it); without it the call logs a warning and sends nothing. An event tracked while init() is still running is held (up to 100) and sent with its original timestamp once init() has the install ID, or dropped with one warning if that launch's report fails. Devices whose first-launch report failed on SDK 1.9.0 or earlier (no install ID) report the install once after upgrading; it appears as an install dated at the upgrade, usually organic, and only restores the install ID: it does not call onDeferredDeepLink, save link data for getInstallData(), or credit a link for later events. There is no offline queue: a failed send is logged and dropped, not retried. ```typescript await LinkFortySDK.trackEvent('purchase', { diff --git a/src/DeepLinkHandler.ts b/src/DeepLinkHandler.ts index a223959..9773eca 100644 --- a/src/DeepLinkHandler.ts +++ b/src/DeepLinkHandler.ts @@ -25,6 +25,8 @@ export class DeepLinkHandler { private readonly debug: boolean; /** The `Linking` 'url' subscription, added once and removed by cleanup() */ private subscription: { remove: () => void } | null = null; + /** Whether the launch URL has been asked for; it is handled once per handler */ + private initialUrlChecked = false; /** * @param options.debug - Log each link handled, its resolution and fallbacks @@ -91,7 +93,8 @@ export class DeepLinkHandler { /** * Initialize deep link listener. Calling it again replaces the callback - * (the newest one receives links) without adding a second listener. + * (the newest one receives links) without adding a second listener or + * delivering the launch URL again. * @param baseUrl - LinkForty instance base URL for detecting LinkForty URLs * @param callback - Callback invoked with parsed/resolved deep link data * @param resolveFn - Optional function to resolve LinkForty URLs via the server API @@ -115,7 +118,10 @@ export class DeepLinkHandler { this.subscription = Linking.addEventListener('url', this.handleDeepLink); } - // Check if app was opened via deep link + // Check if app was opened via deep link (once: a later initialize() must + // not deliver the launch URL a second time) + if (this.initialUrlChecked) return; + this.initialUrlChecked = true; Linking.getInitialURL().then((url) => { this.log('getInitialURL result:', url); if (url) { @@ -134,6 +140,7 @@ export class DeepLinkHandler { this.subscription?.remove(); this.subscription = null; this.callback = null; + this.initialUrlChecked = false; } /** diff --git a/src/DeferredDeepLink.ts b/src/DeferredDeepLink.ts index f0d39c2..46bc7e1 100644 --- a/src/DeferredDeepLink.ts +++ b/src/DeferredDeepLink.ts @@ -1,6 +1,7 @@ import type { DeepLinkData, DeferredDeepLinkCallback, + DeferredDeepLinkResult, InstallAttributionResponse, } from './types'; @@ -36,26 +37,25 @@ export function deferredDeepLinkFromInstall( /** * Delivers the deferred deep link once, on the launch that reports the install: - * the link's data, or null for an organic install. A callback registered after - * that is called with the same result; on later launches nothing is delivered. + * the link's data, or null for an organic install or a failed report, with a + * result that says which. A callback registered after that is called with the + * same result; on later launches nothing is delivered. * * Kept free of React Native imports so it can be tested (see urlParameters.ts). */ export class DeferredDeepLink { private callback: DeferredDeepLinkCallback | null = null; - private delivered = false; - private data: DeepLinkData | null = null; + private delivered: { data: DeepLinkData | null; result: DeferredDeepLinkResult } | null = null; /** Registers the callback, replacing any earlier one. */ register(callback: DeferredDeepLinkCallback): void { this.callback = callback; - if (this.delivered) callback(this.data); + if (this.delivered) callback(this.delivered.data, this.delivered.result); } /** Delivers the install's result. Called only on the launch that reports the install. */ - deliver(data: DeepLinkData | null): void { - this.data = data; - this.delivered = true; - this.callback?.(data); + deliver(data: DeepLinkData | null, result: DeferredDeepLinkResult): void { + this.delivered = { data, result }; + this.callback?.(data, result); } } diff --git a/src/FingerprintCollector.ts b/src/FingerprintCollector.ts index 6de3fd9..2051fd8 100644 --- a/src/FingerprintCollector.ts +++ b/src/FingerprintCollector.ts @@ -7,6 +7,9 @@ import DeviceInfo from 'react-native-device-info'; import type { DeviceFingerprint } from './types'; import { normalizeLanguageTag } from './languageTag'; +/** How long the device's user agent lookup may take before a generated one is used. */ +export const USER_AGENT_TIMEOUT_MS = 3000; + export class FingerprintCollector { /** * Collect device fingerprint data @@ -16,21 +19,61 @@ export class FingerprintCollector { const timezone = this.getTimezone(); const language = this.getLanguage(); + const [deviceModel, osVersion, appVersion] = await Promise.all([ + DeviceInfo.getModel(), + DeviceInfo.getSystemVersion(), + DeviceInfo.getVersion(), + ]); + // Round dimensions — Android returns floats (e.g. 434.717) but the server expects integers const fingerprint: DeviceFingerprint = { - userAgent: await DeviceInfo.getUserAgent(), + userAgent: await this.getUserAgent(osVersion, appVersion), timezone, language, screenResolution: `${Math.round(width)}x${Math.round(height)}`, platform: Platform.OS, - deviceModel: await DeviceInfo.getModel(), - osVersion: await DeviceInfo.getSystemVersion(), - appVersion: await DeviceInfo.getVersion(), + deviceModel, + osVersion, + appVersion, }; return fingerprint; } + /** + * The device's web user agent, or a generated `/ iOS/` + * (or `Android/`) one when the lookup fails or takes longer than + * USER_AGENT_TIMEOUT_MS. On iOS the lookup runs a WKWebView on the main + * thread and can fail to settle; the generated form is the one the native + * SDKs send, which the server matches by platform and OS version. + */ + private static async getUserAgent(osVersion: string, appVersion: string): Promise { + let timer: ReturnType | undefined; + try { + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('user agent lookup timed out')), USER_AGENT_TIMEOUT_MS); + }); + const userAgent = await Promise.race([DeviceInfo.getUserAgent(), timeout]); + if (typeof userAgent === 'string' && userAgent !== '' && userAgent !== 'unknown') { + return userAgent; + } + } catch { + // Fall through to the generated user agent + } finally { + if (timer !== undefined) clearTimeout(timer); + } + + const platform = Platform.OS === 'ios' ? 'iOS' : Platform.OS === 'android' ? 'Android' : Platform.OS; + let appName = 'App'; + try { + const name = typeof DeviceInfo.getApplicationName === 'function' ? DeviceInfo.getApplicationName() : ''; + if (typeof name === 'string' && name !== '' && name !== 'unknown') appName = name; + } catch { + // Keep the default + } + return `${appName}/${appVersion} ${platform}/${osVersion}`; + } + /** * Generate query parameters from fingerprint for URL */ diff --git a/src/LinkFortySDK.ts b/src/LinkFortySDK.ts index 0c17e98..192715f 100644 --- a/src/LinkFortySDK.ts +++ b/src/LinkFortySDK.ts @@ -16,11 +16,31 @@ import type { InstallAttributionResponse, DeepLinkData, DeferredDeepLinkCallback, + DeferredDeepLinkResult, DeepLinkCallback, CreateLinkOptions, CreateLinkResult, } from './types'; +/** How long a request may take before it is abandoned (`requestTimeoutMs`). */ +export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; + +/** How many events tracked while init() runs are held for it to finish. */ +const PENDING_EVENT_LIMIT = 100; + +/** What a launch's install report delivers to the deferred deep link callback. */ +interface DeferredOutcome { + data: DeepLinkData | null; + result: DeferredDeepLinkResult; +} + +/** An event tracked before init() finished, sent once the install ID is known. */ +interface PendingEvent { + name: string; + properties: Record; + timestamp: string; +} + const STORAGE_KEYS = { INSTALL_ID: '@linkforty:install_id', INSTALL_DATA: '@linkforty:install_data', @@ -53,28 +73,65 @@ export class LinkFortySDK { private navigationTracker: NavigationTracker | null = null; /** The workspace's link hosts, which decide which URLs are looked up as links */ private linkDomains: LinkDomains | null = null; + /** Events tracked while init() runs, sent once it has the install ID */ + private pendingEvents: PendingEvent[] = []; + /** Whether this launch has already logged that storage is unavailable */ + private storageErrorLogged = false; + /** The first init() call's promise; later calls return it */ + private initPromise: Promise | null = null; /** - * Initialize the SDK + * Initialize the SDK. Resolves once the install is reported (or the report + * has failed) and the workspace's link hosts are being learned. Never + * rejects for a network or storage failure; a thrown error can only come + * from the app's own onDeferredDeepLink callback. + * + * A second call, even one made while the first is still running (React 18 + * StrictMode runs effects twice in development), logs a warning and returns + * the first call's promise, so there is only ever one handler and listener. */ - async init(config: LinkFortyConfig): Promise { - if (this.initialized) { + init(config: LinkFortyConfig): Promise { + if (this.initPromise) { console.warn('LinkForty SDK already initialized'); - return; + return this.initPromise; } + this.initPromise = this.initialize(config); + return this.initPromise; + } + private async initialize(config: LinkFortyConfig): Promise { + // Everything onDeepLink() needs is set before the first await, so an app + // may call it right after init() without awaiting it: the handler it + // registers on is this one, and it is given the link hosts below, which + // it waits for (up to 3 seconds) on any link handled before they arrive. this.config = config; if (config.debug) { console.log('[LinkForty] Initializing SDK with config:', redactConfig(config)); } + // Learn the workspace's link hosts in the background + this.linkDomains = new LinkDomains( + config.baseUrl, + (path) => this.apiRequest<{ domains: string[] }>(path), + config.appToken, + config.debug, + ); + this.linkDomains.start(); + + // The deep link handler for direct deep links + this.deepLinkHandler = new DeepLinkHandler({ debug: config.debug }); + // Recreate the attribution context so it honors the debug flag, then restore // any persisted active context (a prior deep-link open) before we attribute // the install or load cached data. this.attribution = new AttributionContext({ debug: config.debug }); await this.attribution.load(); + // The deferred deep link, delivered once the SDK is initialized (below), + // so an app callback that throws leaves a working SDK behind. + let deferred: DeferredOutcome | null = null; + if (await this.isInstallReported()) { // Load cached install data await this.loadInstallData(); @@ -82,22 +139,10 @@ export class LinkFortySDK { // First launch, or an earlier report failed: report the install and get // the deferred deep link. With the first-launch flag already set, this // is a device recovering from a report that failed on 1.9.0 or earlier. - const recovery = (await AsyncStorage.getItem(STORAGE_KEYS.INSTALL_REPORTED)) !== null; - await this.reportInstall({ recovery }); + const recovery = (await this.readStorage(STORAGE_KEYS.INSTALL_REPORTED)) !== null; + deferred = await this.reportInstall({ recovery }); } - // Learn the workspace's link hosts in the background - this.linkDomains = new LinkDomains( - config.baseUrl, - (path) => this.apiRequest<{ domains: string[] }>(path), - config.appToken, - config.debug, - ); - this.linkDomains.start(); - - // Initialize deep link handler for direct deep links - this.deepLinkHandler = new DeepLinkHandler({ debug: config.debug }); - // Start auto screen-view tracking if requested. Screen views flow through // trackEvent, so they inherit the last-click attribution stamp. Guarded so a // missing/invalid navigationRef (or no react-navigation) is a no-op. @@ -131,6 +176,13 @@ export class LinkFortySDK { if (config.debug) { console.log('[LinkForty] SDK initialized successfully'); } + + // sendEvent never rejects, so awaiting init() doesn't wait on the sends + void this.flushPendingEvents(); + + if (deferred) { + this.deferredDeepLink.deliver(deferred.data, deferred.result); + } } /** @@ -181,9 +233,10 @@ export class LinkFortySDK { /** * Register callback for deferred deep links, called once on the launch that - * reports the install: with the link's data, or null for an organic install. - * Can be registered before or after init(). Later launches don't call it; - * use getInstallData() for the install's link data. + * reports the install: with the link's data, or null for an organic install + * or a failed report, and a result that says which (see + * DeferredDeepLinkResult). Can be registered before or after init(). Later + * launches don't call it; use getInstallData() for the install's link data. */ onDeferredDeepLink(callback: DeferredDeepLinkCallback): void { this.deferredDeepLink.register(callback); @@ -193,12 +246,17 @@ export class LinkFortySDK { * Register callback for direct deep links (existing users). * When a LinkForty URL is detected, the SDK will resolve it via the server * to retrieve the full deep link data (custom parameters, UTM params, etc.). + * + * May be called as soon as init() has been called, without awaiting it. + * Throws only if init() was never called. */ onDeepLink(callback: DeepLinkCallback): void { if (!this.config) { throw new Error('SDK not initialized. Call init() first.'); } + // init() creates the handler before its first await; this only guards a + // config set by other means. if (!this.deepLinkHandler) { this.deepLinkHandler = new DeepLinkHandler({ debug: this.config.debug }); } @@ -236,18 +294,54 @@ export class LinkFortySDK { } /** - * Track in-app event + * Track in-app event. An event tracked while init() is still running is + * held (up to 100) and sent, with the time it was tracked, once init() has + * the install ID; without one it is dropped with a warning. */ async trackEvent(name: string, properties?: Record): Promise { if (!this.config) { throw new Error('SDK not initialized. Call init() first.'); } + const event: PendingEvent = { + name, + properties: properties || {}, + timestamp: new Date().toISOString(), + }; + if (!this.installId) { + if (!this.initialized) { + if (this.pendingEvents.length < PENDING_EVENT_LIMIT) { + this.pendingEvents.push(event); + } else if (this.config.debug) { + console.warn('[LinkForty] Too many events before init() finished; dropped:', name); + } + return; + } console.warn('[LinkForty] Cannot track event: No install ID available'); return; } + await this.sendEvent(event); + } + + /** Sends the events held while init() ran, or drops them without an install ID. */ + private async flushPendingEvents(): Promise { + const events = this.pendingEvents; + this.pendingEvents = []; + if (events.length === 0) return; + + if (!this.installId) { + console.warn(`[LinkForty] Cannot track ${events.length} event(s): No install ID available`); + return; + } + + await Promise.all(events.map((event) => this.sendEvent(event))); + } + + private async sendEvent(event: PendingEvent): Promise { + if (!this.config || !this.installId) return; + // Backend expects: { installId, eventName, eventData, timestamp } and now // also accepts the last-click attribution stamp ({ attributedLinkId, // attributedClickId?, linkOpenedAt?, sessionId }) so screen views and custom @@ -255,9 +349,9 @@ export class LinkFortySDK { // extra fields (Zod strips unknown keys), so this stays backward-compatible. const requestBody = { installId: this.installId, - eventName: name, - eventData: properties || {}, - timestamp: new Date().toISOString(), + eventName: event.name, + eventData: event.properties, + timestamp: event.timestamp, ...this.attribution.getStamp(), // SDK identity, so the server can tell which SDK and version sent it sdkName: SDK_NAME, @@ -271,7 +365,7 @@ export class LinkFortySDK { }); if (this.config.debug) { - console.log('[LinkForty] Event tracked:', name, response); + console.log('[LinkForty] Event tracked:', event.name, response); } } catch (error) { console.error('[LinkForty] Failed to track event:', error); @@ -473,30 +567,67 @@ export class LinkFortySDK { */ private async isInstallReported(): Promise { const [installId, accepted] = await Promise.all([ - AsyncStorage.getItem(STORAGE_KEYS.INSTALL_ID), - AsyncStorage.getItem(STORAGE_KEYS.INSTALL_REPORT_ACCEPTED), + this.readStorage(STORAGE_KEYS.INSTALL_ID), + this.readStorage(STORAGE_KEYS.INSTALL_REPORT_ACCEPTED), ]); return installId !== null || accepted !== null; } + /** + * Reads a stored value; a storage failure reads as absent. The SDK then + * behaves as on a first launch: it reports the install and delivers the + * deferred deep link, so the app keeps working for the session, and it + * logs the failure once per launch so the integration gets fixed. Storage + * fails when the AsyncStorage native module isn't linked. + */ + private async readStorage(key: string): Promise { + try { + return await AsyncStorage.getItem(key); + } catch (error) { + this.storageFailed(error); + return null; + } + } + + /** Stores a value; a storage failure is logged once per launch and otherwise ignored. */ + private async writeStorage(key: string, value: string): Promise { + try { + await AsyncStorage.setItem(key, value); + } catch (error) { + this.storageFailed(error); + } + } + + private storageFailed(error: unknown): void { + if (this.storageErrorLogged) return; + this.storageErrorLogged = true; + console.error( + '[LinkForty] Storage is unavailable (is @react-native-async-storage/async-storage installed and linked?). The install is reported on every launch until it works:', + error, + ); + } + /** * Report app installation to LinkForty backend. Marks the install reported * only once the server accepts it; on failure the next launch reports again. + * Returns what to deliver to the deferred deep link callback, or null for a + * recovery report, which delivers nothing. * * A recovery report (a device whose report failed on 1.9.0 or earlier) only * restores the install ID: it doesn't call the deferred deep link callback * (the user has been using the app, so routing them like a fresh install * would be wrong), save link data or set the link credited for events. */ - private async reportInstall({ recovery = false }: { recovery?: boolean } = {}): Promise { - const deliver = (data: DeepLinkData | null) => { - if (!recovery) this.deferredDeepLink.deliver(data); - }; - + private async reportInstall({ recovery = false }: { recovery?: boolean } = {}): Promise { if (!this.config) { - return; + return null; } + // Delivered by init() once, outside this try/catch, so an app callback + // that throws surfaces as such instead of being logged as a failed report + // and followed by a second, contradicting delivery. + let outcome: DeferredOutcome | null = null; + try { // Collect device fingerprint, and on Android the Play install referrer, // which names the click exactly @@ -554,12 +685,12 @@ export class LinkFortySDK { // Store install ID if (response.installId) { this.installId = response.installId; - await AsyncStorage.setItem(STORAGE_KEYS.INSTALL_ID, response.installId); + await this.writeStorage(STORAGE_KEYS.INSTALL_ID, response.installId); } // The server accepted the report: don't report this install again - await AsyncStorage.setItem(STORAGE_KEYS.INSTALL_REPORT_ACCEPTED, 'true'); - await AsyncStorage.setItem(STORAGE_KEYS.INSTALL_REPORTED, 'true'); + await this.writeStorage(STORAGE_KEYS.INSTALL_REPORT_ACCEPTED, 'true'); + await this.writeStorage(STORAGE_KEYS.INSTALL_REPORTED, 'true'); // A recovery report only restores the install ID. Its match is a // fingerprint match made now, not the click that led to the install, so @@ -569,16 +700,13 @@ export class LinkFortySDK { if (this.config.debug) { console.log('[LinkForty] Install ID restored; link data from this report is not used'); } - return; + return null; } // If attributed, store deep link data const deepLinkData = deferredDeepLinkFromInstall(response); if (deepLinkData) { - await AsyncStorage.setItem( - STORAGE_KEYS.INSTALL_DATA, - JSON.stringify(deepLinkData) - ); + await this.writeStorage(STORAGE_KEYS.INSTALL_DATA, JSON.stringify(deepLinkData)); // Seed the last-click attribution context from the deferred deep link so // the new user's first-session screen views + events are credited to the @@ -587,7 +715,7 @@ export class LinkFortySDK { await this.attribution.recordDeepLinkOpen(deepLinkData.linkId); } - deliver(deepLinkData); + outcome = { data: deepLinkData, result: { status: 'attributed' } }; if (this.config.debug) { console.log('[LinkForty] Install attributed with confidence:', response.confidenceScore); @@ -600,21 +728,27 @@ export class LinkFortySDK { } // null indicates an organic install - deliver(null); + outcome = { data: null, result: { status: 'organic' } }; } } catch (error) { console.error('[LinkForty] Failed to report install (retried on the next launch):', error); - // null on error - deliver(null); + // null on error; the result says the report failed, and the next launch + // reports again + outcome = { + data: null, + result: { status: 'failed', error: error instanceof Error ? error : new Error(String(error)) }, + }; } + + return recovery ? null : outcome; } /** * Load cached install data */ private async loadInstallData(): Promise { - this.installId = await AsyncStorage.getItem(STORAGE_KEYS.INSTALL_ID); + this.installId = await this.readStorage(STORAGE_KEYS.INSTALL_ID); } /** @@ -643,17 +777,44 @@ export class LinkFortySDK { headers['X-LinkForty-App-Token'] = this.config.appToken; } - const response = await fetch(url, { - ...options, - headers: { - ...headers, - ...options.headers, - }, - }); + // React Native sets no network timeout on Android, so every request is + // abandoned after requestTimeoutMs. AbortController is in React Native + // since 0.60; the guard keeps older or custom runtimes working untimed. + const timeoutMs = this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; + + let response: Response; + try { + response = await fetch(url, { + ...options, + ...(controller && { signal: controller.signal }), + headers: { + ...headers, + ...options.headers, + }, + }); + } catch (error) { + if (controller?.signal.aborted) { + const timeout: Error & { code?: string } = new Error( + `Request timed out after ${timeoutMs}ms: ${options.method || 'GET'} ${endpoint}`, + ); + timeout.code = 'timeout'; + throw timeout; + } + throw error; + } finally { + if (timer !== null) clearTimeout(timer); + } if (!response.ok) { - const body = await response.json().catch(() => ({ message: 'Network error' })); - const error: Error & { status?: number } = new Error(body.message || `HTTP ${response.status}`); + // The status always names the failure; a JSON body's message (or Core's + // `error`) is appended. A proxy's HTML error page adds nothing. + const body = (await response.json().catch(() => null)) as { message?: unknown; error?: unknown } | null; + const detail = [body?.message, body?.error].find((v) => typeof v === 'string' && v !== ''); + const error: Error & { status?: number } = new Error( + detail ? `HTTP ${response.status}: ${detail}` : `HTTP ${response.status}`, + ); error.status = response.status; throw error; } diff --git a/src/index.ts b/src/index.ts index eabe811..9d65689 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ export type { NavigationRouteLike, NavigationContainerRefLike, DeferredDeepLinkCallback, + DeferredDeepLinkResult, DeepLinkCallback, ResolveFunction, CreateLinkOptions, diff --git a/src/types.ts b/src/types.ts index bc6e938..a9047ec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,14 @@ export interface LinkFortyConfig { debug?: boolean; /** Custom attribution window in days (default: 7) */ attributionWindow?: number; + /** + * How long a request to the server may take, in milliseconds, before it is + * abandoned (default: 10000). Applies to every request the SDK makes: the + * install report, link lookups, events and `createLink()`. React Native sets + * no network timeout of its own on Android, so without this a server that + * accepts a connection and never answers would leave `init()` pending. + */ + requestTimeoutMs?: number; /** * Auto-emit `screen_view` events from React Navigation state (no manual * per-screen calls). Requires `navigationRef`. Off by default. @@ -274,9 +282,31 @@ export interface EventData { } /** - * Callback function for deferred deep links + * How the launch's install report ended, passed to the deferred deep link + * callback with the link data. + * + * - `attributed`: the server matched the install to a link click; the data + * is that link's. + * - `organic`: the server accepted the report and matched no click. + * - `failed`: the report did not reach the server or was refused (`error` + * says why). The install is reported again on the next launch, which calls + * the callback again with that report's result. + */ +export interface DeferredDeepLinkResult { + status: 'attributed' | 'organic' | 'failed'; + /** Why the report failed; set only with status `failed` */ + error?: Error; +} + +/** + * Callback function for deferred deep links. `result` tells an attributed + * install, an organic one and a failed report apart; a callback that takes + * only the data keeps working. */ -export type DeferredDeepLinkCallback = (deepLinkData: DeepLinkData | null) => void; +export type DeferredDeepLinkCallback = ( + deepLinkData: DeepLinkData | null, + result: DeferredDeepLinkResult, +) => void; /** * Callback function for direct deep links