deep-link-expo 1.0.0
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.
- package/CHANGELOG.md +70 -0
- package/LICENSE +22 -0
- package/README.md +138 -0
- package/dist/attribution/attribution-context.d.ts +34 -0
- package/dist/attribution/attribution-context.d.ts.map +1 -0
- package/dist/attribution/attribution-context.js +98 -0
- package/dist/attribution/attribution-manager.d.ts +18 -0
- package/dist/attribution/attribution-manager.d.ts.map +1 -0
- package/dist/attribution/attribution-manager.js +94 -0
- package/dist/deeplink/deep-link-handler.d.ts +43 -0
- package/dist/deeplink/deep-link-handler.d.ts.map +1 -0
- package/dist/deeplink/deep-link-handler.js +150 -0
- package/dist/deeplink/url-parser.d.ts +13 -0
- package/dist/deeplink/url-parser.d.ts.map +1 -0
- package/dist/deeplink/url-parser.js +92 -0
- package/dist/errors/linkforty-error.d.ts +26 -0
- package/dist/errors/linkforty-error.d.ts.map +1 -0
- package/dist/errors/linkforty-error.js +50 -0
- package/dist/events/event-queue.d.ts +14 -0
- package/dist/events/event-queue.d.ts.map +1 -0
- package/dist/events/event-queue.js +68 -0
- package/dist/events/event-tracker.d.ts +18 -0
- package/dist/events/event-tracker.d.ts.map +1 -0
- package/dist/events/event-tracker.js +92 -0
- package/dist/fingerprint/fingerprint-collector.d.ts +8 -0
- package/dist/fingerprint/fingerprint-collector.d.ts.map +1 -0
- package/dist/fingerprint/fingerprint-collector.js +27 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/linkforty-sdk.d.ts +53 -0
- package/dist/linkforty-sdk.d.ts.map +1 -0
- package/dist/linkforty-sdk.js +255 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +18 -0
- package/dist/models/attribution.d.ts +28 -0
- package/dist/models/attribution.d.ts.map +1 -0
- package/dist/models/attribution.js +9 -0
- package/dist/models/config.d.ts +72 -0
- package/dist/models/config.d.ts.map +1 -0
- package/dist/models/config.js +26 -0
- package/dist/models/create-link-options.d.ts +15 -0
- package/dist/models/create-link-options.d.ts.map +1 -0
- package/dist/models/create-link-options.js +1 -0
- package/dist/models/create-link-result.d.ts +8 -0
- package/dist/models/create-link-result.d.ts.map +1 -0
- package/dist/models/create-link-result.js +1 -0
- package/dist/models/deep-link-data.d.ts +14 -0
- package/dist/models/deep-link-data.d.ts.map +1 -0
- package/dist/models/deep-link-data.js +1 -0
- package/dist/models/device-fingerprint.d.ts +13 -0
- package/dist/models/device-fingerprint.d.ts.map +1 -0
- package/dist/models/device-fingerprint.js +1 -0
- package/dist/models/event-request.d.ts +13 -0
- package/dist/models/event-request.d.ts.map +1 -0
- package/dist/models/event-request.js +1 -0
- package/dist/models/install-response.d.ts +9 -0
- package/dist/models/install-response.d.ts.map +1 -0
- package/dist/models/install-response.js +1 -0
- package/dist/models/utm-parameters.d.ts +8 -0
- package/dist/models/utm-parameters.d.ts.map +1 -0
- package/dist/models/utm-parameters.js +1 -0
- package/dist/navigation/navigation-tracker.d.ts +52 -0
- package/dist/navigation/navigation-tracker.d.ts.map +1 -0
- package/dist/navigation/navigation-tracker.js +153 -0
- package/dist/network/network-manager.d.ts +13 -0
- package/dist/network/network-manager.d.ts.map +1 -0
- package/dist/network/network-manager.js +90 -0
- package/dist/storage/storage-keys.d.ts +8 -0
- package/dist/storage/storage-keys.d.ts.map +1 -0
- package/dist/storage/storage-keys.js +7 -0
- package/dist/storage/storage-manager.d.ts +20 -0
- package/dist/storage/storage-manager.d.ts.map +1 -0
- package/dist/storage/storage-manager.js +46 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/llms.txt +392 -0
- package/package.json +83 -0
- package/src/attribution/attribution-context.ts +106 -0
- package/src/attribution/attribution-manager.ts +126 -0
- package/src/deeplink/deep-link-handler.ts +171 -0
- package/src/deeplink/url-parser.ts +96 -0
- package/src/errors/linkforty-error.ts +89 -0
- package/src/events/event-queue.ts +75 -0
- package/src/events/event-tracker.ts +118 -0
- package/src/fingerprint/fingerprint-collector.ts +35 -0
- package/src/index.ts +29 -0
- package/src/linkforty-sdk.ts +342 -0
- package/src/logger.ts +23 -0
- package/src/models/attribution.ts +29 -0
- package/src/models/config.ts +109 -0
- package/src/models/create-link-options.ts +15 -0
- package/src/models/create-link-result.ts +7 -0
- package/src/models/deep-link-data.ts +14 -0
- package/src/models/device-fingerprint.ts +12 -0
- package/src/models/event-request.ts +15 -0
- package/src/models/install-response.ts +9 -0
- package/src/models/utm-parameters.ts +7 -0
- package/src/navigation/navigation-tracker.ts +195 -0
- package/src/network/network-manager.ts +111 -0
- package/src/storage/storage-keys.ts +7 -0
- package/src/storage/storage-manager.ts +61 -0
- package/src/version.ts +3 -0
package/llms.txt
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
# @linkforty/mobile-sdk-expo
|
|
2
|
+
|
|
3
|
+
> Expo SDK for LinkForty — an open-source mobile attribution and deep link management platform. Pure JavaScript implementation for Expo apps. Handles deep linking, deferred deep linking (install attribution), in-app event tracking with offline queue, revenue tracking, and link creation. Works with LinkForty Cloud (linkforty.com) or self-hosted Core instances.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx expo install @linkforty/mobile-sdk-expo expo-device expo-application expo-localization expo-linking @react-native-async-storage/async-storage
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
All dependencies are Expo-compatible and use the Expo module system.
|
|
12
|
+
|
|
13
|
+
## TypeScript Types
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
interface LinkFortyConfig {
|
|
17
|
+
/** Base URL of your LinkForty instance (e.g., 'https://go.yourdomain.com') */
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
/** API key — only needed if calling createLink() */
|
|
20
|
+
apiKey?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Public workspace token (LinkForty Cloud only). Recommended — required
|
|
23
|
+
* for organic installs to be attributed to your workspace. Find it in
|
|
24
|
+
* the dashboard under Workspace Settings → App Token. Format:
|
|
25
|
+
* `at_<32 hex chars>`. Safe to ship in your app bundle.
|
|
26
|
+
*/
|
|
27
|
+
appToken?: string;
|
|
28
|
+
/** Enable debug logging (default: false) */
|
|
29
|
+
debug?: boolean;
|
|
30
|
+
/** Attribution window in hours (default: 168 = 7 days, range: 1-2160) */
|
|
31
|
+
attributionWindowHours?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface DeepLinkData {
|
|
35
|
+
shortCode: string;
|
|
36
|
+
iosUrl?: string;
|
|
37
|
+
androidUrl?: string;
|
|
38
|
+
webUrl?: string;
|
|
39
|
+
utmParameters?: {
|
|
40
|
+
source?: string;
|
|
41
|
+
medium?: string;
|
|
42
|
+
campaign?: string;
|
|
43
|
+
term?: string;
|
|
44
|
+
content?: string;
|
|
45
|
+
};
|
|
46
|
+
/** Custom key-value parameters set when the link was created */
|
|
47
|
+
customParameters?: Record<string, string>;
|
|
48
|
+
/** In-app destination path (e.g., '/product/123') */
|
|
49
|
+
deepLinkPath?: string;
|
|
50
|
+
/** Custom URI scheme (e.g., 'myapp') */
|
|
51
|
+
appScheme?: string;
|
|
52
|
+
clickedAt?: string;
|
|
53
|
+
linkId?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface InstallAttributionResponse {
|
|
57
|
+
installId: string;
|
|
58
|
+
attributed: boolean;
|
|
59
|
+
/** Attribution confidence score (0-100) */
|
|
60
|
+
confidenceScore: number;
|
|
61
|
+
/** Matched fingerprint factors (e.g., ['ip', 'user_agent', 'timezone']) */
|
|
62
|
+
matchedFactors: string[];
|
|
63
|
+
deepLinkData: DeepLinkData | {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface CreateLinkOptions {
|
|
67
|
+
/** Template ID (UUID) — auto-selected when omitted */
|
|
68
|
+
templateId?: string;
|
|
69
|
+
/** Template slug — only needed when templateId is provided */
|
|
70
|
+
templateSlug?: string;
|
|
71
|
+
/** Custom parameters embedded in the link */
|
|
72
|
+
deepLinkParameters?: Record<string, string>;
|
|
73
|
+
title?: string;
|
|
74
|
+
description?: string;
|
|
75
|
+
/** Custom short code (auto-generated if omitted) */
|
|
76
|
+
customCode?: string;
|
|
77
|
+
utmParameters?: {
|
|
78
|
+
source?: string;
|
|
79
|
+
medium?: string;
|
|
80
|
+
campaign?: string;
|
|
81
|
+
term?: string;
|
|
82
|
+
content?: string;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface CreateLinkResult {
|
|
87
|
+
/** Full shareable URL (e.g., 'https://go.example.com/tmpl/abc123') */
|
|
88
|
+
url: string;
|
|
89
|
+
shortCode: string;
|
|
90
|
+
linkId: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** All SDK errors are this type */
|
|
94
|
+
class LinkFortyError extends Error {
|
|
95
|
+
code: 'NOT_INITIALIZED' | 'ALREADY_INITIALIZED' | 'INVALID_CONFIGURATION'
|
|
96
|
+
| 'NETWORK_ERROR' | 'INVALID_RESPONSE' | 'DECODING_ERROR'
|
|
97
|
+
| 'INVALID_EVENT_DATA' | 'MISSING_API_KEY';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type DeepLinkCallback = (url: string, deepLinkData: DeepLinkData | null) => void;
|
|
101
|
+
type DeferredDeepLinkCallback = (deepLinkData: DeepLinkData | null) => void;
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## SDK API
|
|
105
|
+
|
|
106
|
+
The SDK exports a singleton default export: `LinkFortySDK`.
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import LinkFortySDK from '@linkforty/mobile-sdk-expo';
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### initialize(config: LinkFortyConfig): Promise\<InstallAttributionResponse\>
|
|
113
|
+
|
|
114
|
+
Initialize the SDK, report the install, and return attribution data immediately. Must be called before any other method. HTTPS is required for `baseUrl` (except localhost for development).
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
const attribution = await LinkFortySDK.initialize({
|
|
118
|
+
baseUrl: 'https://go.yourdomain.com',
|
|
119
|
+
apiKey: 'lf_live_abc123', // optional — only for createLink()
|
|
120
|
+
appToken: 'at_a1b2c3d4...', // recommended for Cloud — enables organic-install attribution
|
|
121
|
+
debug: __DEV__,
|
|
122
|
+
attributionWindowHours: 168, // 7 days (default)
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
console.log(attribution.installId); // UUID
|
|
126
|
+
console.log(attribution.attributed); // true/false
|
|
127
|
+
console.log(attribution.confidenceScore); // 0-100
|
|
128
|
+
console.log(attribution.matchedFactors); // ['ip', 'user_agent', ...]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Property:** `LinkFortySDK.isInitialized: boolean` — check if SDK is initialized.
|
|
132
|
+
|
|
133
|
+
### onDeepLink(callback: DeepLinkCallback): void
|
|
134
|
+
|
|
135
|
+
Register a callback for direct deep links — fires when user taps a LinkForty link and the app is already installed. Supports multiple callbacks.
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
LinkFortySDK.onDeepLink((url, data) => {
|
|
139
|
+
console.log('Deep link URL:', url);
|
|
140
|
+
if (data?.customParameters?.route) {
|
|
141
|
+
router.push(`/${data.customParameters.route}/${data.customParameters.id}`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### onDeferredDeepLink(callback: DeferredDeepLinkCallback): void
|
|
147
|
+
|
|
148
|
+
Register a callback for deferred deep links — fires on first app launch if the install was attributed to a link click.
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
LinkFortySDK.onDeferredDeepLink((data) => {
|
|
152
|
+
if (data) {
|
|
153
|
+
console.log('Attributed install from:', data.shortCode);
|
|
154
|
+
router.push(`/${data.customParameters?.route}/${data.customParameters?.id}`);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### handleDeepLink(url: string): void
|
|
160
|
+
|
|
161
|
+
Manually pass a URL to the SDK for deep link handling. Use when you receive a URL through a channel other than the automatic listener.
|
|
162
|
+
|
|
163
|
+
### trackEvent(name: string, properties?: Record\<string, unknown\>): Promise\<void\>
|
|
164
|
+
|
|
165
|
+
Track in-app events. Failed sends are queued offline (max 100 events) and retried automatically on next successful send.
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
await LinkFortySDK.trackEvent('add_to_cart', {
|
|
169
|
+
productId: '789',
|
|
170
|
+
price: 29.99,
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### trackRevenue(amount: number, currency: string, properties?: Record\<string, unknown\>): Promise\<void\>
|
|
175
|
+
|
|
176
|
+
Track revenue events with a dedicated method for clearer analytics.
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
await LinkFortySDK.trackRevenue(29.99, 'USD', {
|
|
180
|
+
productId: '789',
|
|
181
|
+
orderId: 'order_123',
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### createLink(options: CreateLinkOptions): Promise\<CreateLinkResult\>
|
|
186
|
+
|
|
187
|
+
Create a shareable LinkForty link from within the app. Requires `apiKey` in the init config.
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
const result = await LinkFortySDK.createLink({
|
|
191
|
+
deepLinkParameters: {
|
|
192
|
+
route: 'product',
|
|
193
|
+
productId: '789',
|
|
194
|
+
},
|
|
195
|
+
title: 'Check out this product!',
|
|
196
|
+
utmParameters: {
|
|
197
|
+
source: 'in_app_share',
|
|
198
|
+
medium: 'referral',
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
console.log(result.url); // https://go.yourdomain.com/tmpl/abc12345
|
|
203
|
+
console.log(result.shortCode); // abc12345
|
|
204
|
+
console.log(result.linkId); // uuid
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Offline Event Queue
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
LinkFortySDK.queuedEventCount; // number of events waiting to send
|
|
211
|
+
await LinkFortySDK.flushEvents(); // manually flush the queue
|
|
212
|
+
await LinkFortySDK.clearEventQueue(); // clear queue without sending
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Utility Methods
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
const installId = await LinkFortySDK.getInstallId();
|
|
219
|
+
const installData = await LinkFortySDK.getInstallData();
|
|
220
|
+
const isFirst = await LinkFortySDK.isFirstLaunch();
|
|
221
|
+
|
|
222
|
+
// Clear all stored data (e.g., on user logout)
|
|
223
|
+
await LinkFortySDK.clearData();
|
|
224
|
+
|
|
225
|
+
// Reset SDK to uninitialized state (does NOT clear stored data)
|
|
226
|
+
LinkFortySDK.reset();
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Platform Setup (app.json / app.config.js)
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
{
|
|
233
|
+
"expo": {
|
|
234
|
+
"ios": {
|
|
235
|
+
"associatedDomains": ["applinks:go.yourdomain.com"]
|
|
236
|
+
},
|
|
237
|
+
"android": {
|
|
238
|
+
"intentFilters": [
|
|
239
|
+
{
|
|
240
|
+
"action": "VIEW",
|
|
241
|
+
"autoVerify": true,
|
|
242
|
+
"data": [
|
|
243
|
+
{
|
|
244
|
+
"scheme": "https",
|
|
245
|
+
"host": "go.yourdomain.com"
|
|
246
|
+
}
|
|
247
|
+
],
|
|
248
|
+
"category": ["BROWSABLE", "DEFAULT"]
|
|
249
|
+
}
|
|
250
|
+
]
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
LinkForty automatically serves `/.well-known/apple-app-site-association` (iOS) and `/.well-known/assetlinks.json` (Android) from your domain. No manual file hosting needed.
|
|
257
|
+
|
|
258
|
+
## Complete Integration Example (Expo Router)
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
// app/_layout.tsx
|
|
262
|
+
import { useEffect } from 'react';
|
|
263
|
+
import { Stack, useRouter } from 'expo-router';
|
|
264
|
+
import { Share } from 'react-native';
|
|
265
|
+
import LinkFortySDK, { LinkFortyError } from '@linkforty/mobile-sdk-expo';
|
|
266
|
+
|
|
267
|
+
export default function RootLayout() {
|
|
268
|
+
const router = useRouter();
|
|
269
|
+
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
async function initLinkForty() {
|
|
272
|
+
try {
|
|
273
|
+
const attribution = await LinkFortySDK.initialize({
|
|
274
|
+
baseUrl: 'https://go.yourdomain.com',
|
|
275
|
+
apiKey: 'lf_live_abc123',
|
|
276
|
+
appToken: 'at_a1b2c3d4...',
|
|
277
|
+
debug: __DEV__,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Handle deferred deep links (first open after install)
|
|
281
|
+
LinkFortySDK.onDeferredDeepLink((data) => {
|
|
282
|
+
if (data?.customParameters?.route) {
|
|
283
|
+
router.push(`/${data.customParameters.route}/${data.customParameters.id}`);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Handle direct deep links (app already installed)
|
|
288
|
+
LinkFortySDK.onDeepLink((url, data) => {
|
|
289
|
+
if (data?.customParameters?.route) {
|
|
290
|
+
router.push(`/${data.customParameters.route}/${data.customParameters.id}`);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
if (attribution.attributed) {
|
|
295
|
+
console.log('Install attributed! Confidence:', attribution.confidenceScore);
|
|
296
|
+
}
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (error instanceof LinkFortyError) {
|
|
299
|
+
console.error('LinkForty error:', error.code, error.message);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
initLinkForty();
|
|
305
|
+
}, []);
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<Stack>
|
|
309
|
+
<Stack.Screen name="index" />
|
|
310
|
+
<Stack.Screen name="product/[id]" />
|
|
311
|
+
<Stack.Screen name="profile/[id]" />
|
|
312
|
+
</Stack>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Share a deep link from anywhere in your app
|
|
317
|
+
export async function shareProduct(productId: string, productName: string) {
|
|
318
|
+
const result = await LinkFortySDK.createLink({
|
|
319
|
+
deepLinkParameters: { route: 'product', productId },
|
|
320
|
+
title: productName,
|
|
321
|
+
utmParameters: { source: 'in_app_share', medium: 'referral' },
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await Share.share({
|
|
325
|
+
message: `Check out ${productName}! ${result.url}`,
|
|
326
|
+
url: result.url,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Track a conversion from anywhere in your app
|
|
331
|
+
export async function trackPurchase(orderId: string, amount: number) {
|
|
332
|
+
await LinkFortySDK.trackRevenue(amount, 'USD', { orderId });
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## Error Handling
|
|
337
|
+
|
|
338
|
+
All SDK methods throw `LinkFortyError` with a `.code` property:
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
import LinkFortySDK, { LinkFortyError } from '@linkforty/mobile-sdk-expo';
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
await LinkFortySDK.initialize({ baseUrl: 'https://go.yourdomain.com' });
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (error instanceof LinkFortyError) {
|
|
347
|
+
switch (error.code) {
|
|
348
|
+
case 'ALREADY_INITIALIZED':
|
|
349
|
+
// SDK was already initialized — safe to ignore
|
|
350
|
+
break;
|
|
351
|
+
case 'INVALID_CONFIGURATION':
|
|
352
|
+
console.error('Check your baseUrl:', error.message);
|
|
353
|
+
break;
|
|
354
|
+
case 'NETWORK_ERROR':
|
|
355
|
+
console.error('Could not reach server:', error.message);
|
|
356
|
+
break;
|
|
357
|
+
default:
|
|
358
|
+
console.error('LinkForty error:', error.code, error.message);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Error codes: `NOT_INITIALIZED`, `ALREADY_INITIALIZED`, `INVALID_CONFIGURATION`, `NETWORK_ERROR`, `INVALID_RESPONSE`, `DECODING_ERROR`, `INVALID_EVENT_DATA`, `MISSING_API_KEY`.
|
|
365
|
+
|
|
366
|
+
## Testing Deep Links
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
# iOS Simulator
|
|
370
|
+
xcrun simctl openurl booted "https://go.yourdomain.com/abc123"
|
|
371
|
+
|
|
372
|
+
# Android Emulator
|
|
373
|
+
adb shell am start -a android.intent.action.VIEW -d "https://go.yourdomain.com/abc123"
|
|
374
|
+
|
|
375
|
+
# Expo CLI
|
|
376
|
+
npx uri-scheme open "https://go.yourdomain.com/abc123" --ios
|
|
377
|
+
npx uri-scheme open "https://go.yourdomain.com/abc123" --android
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
## Self-Hosted vs Cloud
|
|
381
|
+
|
|
382
|
+
The only change is the `baseUrl`:
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
// Cloud (managed SaaS)
|
|
386
|
+
await LinkFortySDK.initialize({ baseUrl: 'https://go.linkforty.com' });
|
|
387
|
+
|
|
388
|
+
// Self-hosted (@linkforty/core)
|
|
389
|
+
await LinkFortySDK.initialize({ baseUrl: 'https://links.yourdomain.com' });
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
All SDK methods work identically with both.
|
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "deep-link-expo",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Expo SDK for self-hosted deferred deep linking, attribution, and smart link routing.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "node scripts/gen-version.mjs && tsc",
|
|
9
|
+
"prepare": "npm run build",
|
|
10
|
+
"prepublishOnly": "npm run build",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:watch": "vitest",
|
|
13
|
+
"test:coverage": "vitest run --coverage"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"deep-linking",
|
|
17
|
+
"deferred-deep-linking",
|
|
18
|
+
"mobile-attribution",
|
|
19
|
+
"expo",
|
|
20
|
+
"react-native",
|
|
21
|
+
"universal-links",
|
|
22
|
+
"app-links",
|
|
23
|
+
"self-hosted",
|
|
24
|
+
"open-source"
|
|
25
|
+
],
|
|
26
|
+
"author": "Parth24072001",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/Parth24072001/deep-link.git",
|
|
31
|
+
"directory": "packages/sdk"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/Parth24072001/deep-link/issues"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/Parth24072001/deep-link#readme",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20.0.0",
|
|
39
|
+
"npm": ">=10.0.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@react-navigation/native": ">=6.0.0",
|
|
43
|
+
"expo": ">=51.0.0",
|
|
44
|
+
"react": ">=18.0.0",
|
|
45
|
+
"react-native": ">=0.74.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"@react-navigation/native": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@react-native-async-storage/async-storage": "^2.1.0",
|
|
54
|
+
"expo-application": "~6.0.0",
|
|
55
|
+
"expo-device": "~7.0.0",
|
|
56
|
+
"expo-linking": "~7.0.0",
|
|
57
|
+
"expo-localization": "~16.0.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
61
|
+
"@semantic-release/git": "^10.0.1",
|
|
62
|
+
"@semantic-release/github": "^10.3.5",
|
|
63
|
+
"@semantic-release/npm": "^13.1.5",
|
|
64
|
+
"@types/react": "^19.0.0",
|
|
65
|
+
"@vitest/coverage-v8": "^3.0.0",
|
|
66
|
+
"conventional-changelog-conventionalcommits": "^7.0.2",
|
|
67
|
+
"semantic-release": "^25.0.3",
|
|
68
|
+
"typescript": "^5.7.0",
|
|
69
|
+
"vitest": "^3.0.0"
|
|
70
|
+
},
|
|
71
|
+
"files": [
|
|
72
|
+
"dist/**/*",
|
|
73
|
+
"src/**/*",
|
|
74
|
+
"llms.txt",
|
|
75
|
+
"README.md",
|
|
76
|
+
"CHANGELOG.md",
|
|
77
|
+
"LICENSE"
|
|
78
|
+
],
|
|
79
|
+
"publishConfig": {
|
|
80
|
+
"access": "public",
|
|
81
|
+
"registry": "https://registry.npmjs.org/"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AttributionContext — last-click attribution + session tracking (SIT-237).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the React Native SDK. Every deep-link open (deferred install OR direct
|
|
5
|
+
* re-engagement) pins an active attribution context to THAT link; the newest
|
|
6
|
+
* open supersedes the previous one. Every tracked event is stamped with the
|
|
7
|
+
* active context + a session id so the backend can credit the link under a
|
|
8
|
+
* last-click + window model. The active context is persisted so a reopen without
|
|
9
|
+
* a new click still attributes to the last link; the session is in-memory (a cold
|
|
10
|
+
* start is a new session).
|
|
11
|
+
*
|
|
12
|
+
* Uses AsyncStorage directly (not StorageManager) to stay self-contained.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
16
|
+
import { STORAGE_KEYS } from '../storage/storage-keys';
|
|
17
|
+
import type { ActiveAttribution, AttributionStamp } from '../models/attribution';
|
|
18
|
+
import { logger } from '../logger';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* RFC4122-v4-style id for session grouping. Not a security token — `Math.random`
|
|
22
|
+
* is sufficient and avoids a native crypto dependency.
|
|
23
|
+
*/
|
|
24
|
+
function generateSessionId(): string {
|
|
25
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
26
|
+
const r = (Math.random() * 16) | 0;
|
|
27
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
28
|
+
return v.toString(16);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class AttributionContext {
|
|
33
|
+
private active: ActiveAttribution | null = null;
|
|
34
|
+
private sessionId: string;
|
|
35
|
+
private loaded = false;
|
|
36
|
+
|
|
37
|
+
constructor() {
|
|
38
|
+
// Construction == cold start == a new session.
|
|
39
|
+
this.sessionId = generateSessionId();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Restore the persisted active context. Idempotent; never throws. */
|
|
43
|
+
async load(): Promise<void> {
|
|
44
|
+
if (this.loaded) return;
|
|
45
|
+
try {
|
|
46
|
+
const raw = await AsyncStorage.getItem(STORAGE_KEYS.ATTRIBUTION);
|
|
47
|
+
if (raw) {
|
|
48
|
+
this.active = JSON.parse(raw) as ActiveAttribution;
|
|
49
|
+
}
|
|
50
|
+
} catch (e) {
|
|
51
|
+
logger.warn('Failed to load attribution context:', e);
|
|
52
|
+
}
|
|
53
|
+
this.loaded = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Record a deep-link open. Newest open supersedes (last-click) and starts a new
|
|
58
|
+
* session. No-op when no `linkId` is known (organic open).
|
|
59
|
+
*/
|
|
60
|
+
async recordDeepLinkOpen(linkId?: string | null, clickId?: string | null): Promise<void> {
|
|
61
|
+
if (!linkId) return;
|
|
62
|
+
|
|
63
|
+
this.active = {
|
|
64
|
+
linkId,
|
|
65
|
+
clickId: clickId ?? undefined,
|
|
66
|
+
openedAt: new Date().toISOString(),
|
|
67
|
+
};
|
|
68
|
+
this.sessionId = generateSessionId();
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await AsyncStorage.setItem(STORAGE_KEYS.ATTRIBUTION, JSON.stringify(this.active));
|
|
72
|
+
} catch (e) {
|
|
73
|
+
logger.warn('Failed to persist attribution context:', e);
|
|
74
|
+
}
|
|
75
|
+
logger.log('Attribution context set:', this.active, 'session:', this.sessionId);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Fields to merge into every event payload. */
|
|
79
|
+
getStamp(): AttributionStamp {
|
|
80
|
+
return {
|
|
81
|
+
attributedLinkId: this.active?.linkId,
|
|
82
|
+
attributedClickId: this.active?.clickId,
|
|
83
|
+
linkOpenedAt: this.active?.openedAt,
|
|
84
|
+
sessionId: this.sessionId,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
getSessionId(): string {
|
|
89
|
+
return this.sessionId;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getActive(): ActiveAttribution | null {
|
|
93
|
+
return this.active;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Clear the persisted context and start a fresh session. */
|
|
97
|
+
async clear(): Promise<void> {
|
|
98
|
+
this.active = null;
|
|
99
|
+
this.sessionId = generateSessionId();
|
|
100
|
+
try {
|
|
101
|
+
await AsyncStorage.removeItem(STORAGE_KEYS.ATTRIBUTION);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
logger.warn('Failed to clear attribution context:', e);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { FingerprintCollectorProtocol } from '../fingerprint/fingerprint-collector';
|
|
2
|
+
import type { NetworkManagerProtocol } from '../network/network-manager';
|
|
3
|
+
import type { StorageManagerProtocol } from '../storage/storage-manager';
|
|
4
|
+
import type { InstallAttributionResponse } from '../models/install-response';
|
|
5
|
+
import type { DeepLinkData } from '../models/deep-link-data';
|
|
6
|
+
import { logger } from '../logger';
|
|
7
|
+
import { SDK_NAME, SDK_VERSION } from '../version';
|
|
8
|
+
|
|
9
|
+
export class AttributionManager {
|
|
10
|
+
private readonly network: NetworkManagerProtocol;
|
|
11
|
+
private readonly storage: StorageManagerProtocol;
|
|
12
|
+
private readonly fingerprint: FingerprintCollectorProtocol;
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
network: NetworkManagerProtocol,
|
|
16
|
+
storage: StorageManagerProtocol,
|
|
17
|
+
fingerprint: FingerprintCollectorProtocol,
|
|
18
|
+
) {
|
|
19
|
+
this.network = network;
|
|
20
|
+
this.storage = storage;
|
|
21
|
+
this.fingerprint = fingerprint;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async reportInstall(
|
|
25
|
+
attributionWindowHours: number,
|
|
26
|
+
deviceId?: string,
|
|
27
|
+
appToken?: string,
|
|
28
|
+
): Promise<InstallAttributionResponse> {
|
|
29
|
+
const isFirst = await this.storage.isFirstLaunch();
|
|
30
|
+
|
|
31
|
+
if (!isFirst) {
|
|
32
|
+
return this.buildCachedResponse();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const fp = this.fingerprint.collect(attributionWindowHours, deviceId);
|
|
36
|
+
logger.log('Reporting install with fingerprint:', fp);
|
|
37
|
+
|
|
38
|
+
let response: InstallAttributionResponse;
|
|
39
|
+
try {
|
|
40
|
+
response = await this.network.request<InstallAttributionResponse>(
|
|
41
|
+
'/api/sdk/v1/install',
|
|
42
|
+
{
|
|
43
|
+
method: 'POST',
|
|
44
|
+
// appToken (when provided) lets Cloud scope organic installs
|
|
45
|
+
// to the right workspace. Omitted from the body when undefined.
|
|
46
|
+
// sdkName/sdkVersion (SIT-235) identify the SDK for health diagnostics.
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
...fp,
|
|
49
|
+
...(appToken ? { appToken } : {}),
|
|
50
|
+
sdkName: SDK_NAME,
|
|
51
|
+
sdkVersion: SDK_VERSION,
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
logger.error('Failed to report install:', e);
|
|
57
|
+
// Treat as organic on failure
|
|
58
|
+
await this.storage.setHasLaunched();
|
|
59
|
+
return {
|
|
60
|
+
installId: '',
|
|
61
|
+
attributed: false,
|
|
62
|
+
confidenceScore: 0,
|
|
63
|
+
matchedFactors: [],
|
|
64
|
+
deepLinkData: null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
logger.log('Install response:', response);
|
|
69
|
+
|
|
70
|
+
// Cache install ID
|
|
71
|
+
if (response.installId) {
|
|
72
|
+
await this.storage.saveInstallId(response.installId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Cache deep link data if attributed
|
|
76
|
+
if (response.attributed && response.deepLinkData) {
|
|
77
|
+
// Normalize deepLinkParameters -> customParameters
|
|
78
|
+
const deepLinkData: DeepLinkData = {
|
|
79
|
+
...response.deepLinkData,
|
|
80
|
+
customParameters:
|
|
81
|
+
(response.deepLinkData as DeepLinkData & { deepLinkParameters?: Record<string, string> })
|
|
82
|
+
.deepLinkParameters ?? response.deepLinkData.customParameters,
|
|
83
|
+
};
|
|
84
|
+
await this.storage.saveInstallData(deepLinkData);
|
|
85
|
+
logger.log('Install attributed with confidence:', response.confidenceScore);
|
|
86
|
+
|
|
87
|
+
// Return with normalized data
|
|
88
|
+
response = { ...response, deepLinkData };
|
|
89
|
+
} else {
|
|
90
|
+
logger.log('Organic install (no attribution)');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await this.storage.setHasLaunched();
|
|
94
|
+
return response;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async getInstallId(): Promise<string | null> {
|
|
98
|
+
return this.storage.getInstallId();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async getInstallData(): Promise<DeepLinkData | null> {
|
|
102
|
+
return this.storage.getInstallData();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async isFirstLaunch(): Promise<boolean> {
|
|
106
|
+
return this.storage.isFirstLaunch();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async clearData(): Promise<void> {
|
|
110
|
+
await this.storage.clearAll();
|
|
111
|
+
logger.log('Attribution data cleared');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async buildCachedResponse(): Promise<InstallAttributionResponse> {
|
|
115
|
+
const installId = await this.storage.getInstallId();
|
|
116
|
+
const deepLinkData = await this.storage.getInstallData();
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
installId: installId ?? '',
|
|
120
|
+
attributed: deepLinkData !== null,
|
|
121
|
+
confidenceScore: deepLinkData ? 100 : 0,
|
|
122
|
+
matchedFactors: [],
|
|
123
|
+
deepLinkData,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|