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.
Files changed (105) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +22 -0
  3. package/README.md +138 -0
  4. package/dist/attribution/attribution-context.d.ts +34 -0
  5. package/dist/attribution/attribution-context.d.ts.map +1 -0
  6. package/dist/attribution/attribution-context.js +98 -0
  7. package/dist/attribution/attribution-manager.d.ts +18 -0
  8. package/dist/attribution/attribution-manager.d.ts.map +1 -0
  9. package/dist/attribution/attribution-manager.js +94 -0
  10. package/dist/deeplink/deep-link-handler.d.ts +43 -0
  11. package/dist/deeplink/deep-link-handler.d.ts.map +1 -0
  12. package/dist/deeplink/deep-link-handler.js +150 -0
  13. package/dist/deeplink/url-parser.d.ts +13 -0
  14. package/dist/deeplink/url-parser.d.ts.map +1 -0
  15. package/dist/deeplink/url-parser.js +92 -0
  16. package/dist/errors/linkforty-error.d.ts +26 -0
  17. package/dist/errors/linkforty-error.d.ts.map +1 -0
  18. package/dist/errors/linkforty-error.js +50 -0
  19. package/dist/events/event-queue.d.ts +14 -0
  20. package/dist/events/event-queue.d.ts.map +1 -0
  21. package/dist/events/event-queue.js +68 -0
  22. package/dist/events/event-tracker.d.ts +18 -0
  23. package/dist/events/event-tracker.d.ts.map +1 -0
  24. package/dist/events/event-tracker.js +92 -0
  25. package/dist/fingerprint/fingerprint-collector.d.ts +8 -0
  26. package/dist/fingerprint/fingerprint-collector.d.ts.map +1 -0
  27. package/dist/fingerprint/fingerprint-collector.js +27 -0
  28. package/dist/index.d.ts +18 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +6 -0
  31. package/dist/linkforty-sdk.d.ts +53 -0
  32. package/dist/linkforty-sdk.d.ts.map +1 -0
  33. package/dist/linkforty-sdk.js +255 -0
  34. package/dist/logger.d.ts +7 -0
  35. package/dist/logger.d.ts.map +1 -0
  36. package/dist/logger.js +18 -0
  37. package/dist/models/attribution.d.ts +28 -0
  38. package/dist/models/attribution.d.ts.map +1 -0
  39. package/dist/models/attribution.js +9 -0
  40. package/dist/models/config.d.ts +72 -0
  41. package/dist/models/config.d.ts.map +1 -0
  42. package/dist/models/config.js +26 -0
  43. package/dist/models/create-link-options.d.ts +15 -0
  44. package/dist/models/create-link-options.d.ts.map +1 -0
  45. package/dist/models/create-link-options.js +1 -0
  46. package/dist/models/create-link-result.d.ts +8 -0
  47. package/dist/models/create-link-result.d.ts.map +1 -0
  48. package/dist/models/create-link-result.js +1 -0
  49. package/dist/models/deep-link-data.d.ts +14 -0
  50. package/dist/models/deep-link-data.d.ts.map +1 -0
  51. package/dist/models/deep-link-data.js +1 -0
  52. package/dist/models/device-fingerprint.d.ts +13 -0
  53. package/dist/models/device-fingerprint.d.ts.map +1 -0
  54. package/dist/models/device-fingerprint.js +1 -0
  55. package/dist/models/event-request.d.ts +13 -0
  56. package/dist/models/event-request.d.ts.map +1 -0
  57. package/dist/models/event-request.js +1 -0
  58. package/dist/models/install-response.d.ts +9 -0
  59. package/dist/models/install-response.d.ts.map +1 -0
  60. package/dist/models/install-response.js +1 -0
  61. package/dist/models/utm-parameters.d.ts +8 -0
  62. package/dist/models/utm-parameters.d.ts.map +1 -0
  63. package/dist/models/utm-parameters.js +1 -0
  64. package/dist/navigation/navigation-tracker.d.ts +52 -0
  65. package/dist/navigation/navigation-tracker.d.ts.map +1 -0
  66. package/dist/navigation/navigation-tracker.js +153 -0
  67. package/dist/network/network-manager.d.ts +13 -0
  68. package/dist/network/network-manager.d.ts.map +1 -0
  69. package/dist/network/network-manager.js +90 -0
  70. package/dist/storage/storage-keys.d.ts +8 -0
  71. package/dist/storage/storage-keys.d.ts.map +1 -0
  72. package/dist/storage/storage-keys.js +7 -0
  73. package/dist/storage/storage-manager.d.ts +20 -0
  74. package/dist/storage/storage-manager.d.ts.map +1 -0
  75. package/dist/storage/storage-manager.js +46 -0
  76. package/dist/version.d.ts +3 -0
  77. package/dist/version.d.ts.map +1 -0
  78. package/dist/version.js +3 -0
  79. package/llms.txt +392 -0
  80. package/package.json +83 -0
  81. package/src/attribution/attribution-context.ts +106 -0
  82. package/src/attribution/attribution-manager.ts +126 -0
  83. package/src/deeplink/deep-link-handler.ts +171 -0
  84. package/src/deeplink/url-parser.ts +96 -0
  85. package/src/errors/linkforty-error.ts +89 -0
  86. package/src/events/event-queue.ts +75 -0
  87. package/src/events/event-tracker.ts +118 -0
  88. package/src/fingerprint/fingerprint-collector.ts +35 -0
  89. package/src/index.ts +29 -0
  90. package/src/linkforty-sdk.ts +342 -0
  91. package/src/logger.ts +23 -0
  92. package/src/models/attribution.ts +29 -0
  93. package/src/models/config.ts +109 -0
  94. package/src/models/create-link-options.ts +15 -0
  95. package/src/models/create-link-result.ts +7 -0
  96. package/src/models/deep-link-data.ts +14 -0
  97. package/src/models/device-fingerprint.ts +12 -0
  98. package/src/models/event-request.ts +15 -0
  99. package/src/models/install-response.ts +9 -0
  100. package/src/models/utm-parameters.ts +7 -0
  101. package/src/navigation/navigation-tracker.ts +195 -0
  102. package/src/network/network-manager.ts +111 -0
  103. package/src/storage/storage-keys.ts +7 -0
  104. package/src/storage/storage-manager.ts +61 -0
  105. package/src/version.ts +3 -0
@@ -0,0 +1,195 @@
1
+ /**
2
+ * NavigationTracker — auto-emits `screen_view` events from React Navigation.
3
+ *
4
+ * When `autoTrackNavigation` is enabled with a `navigationRef`, the SDK
5
+ * subscribes to React Navigation's `'state'` event and emits a `screen_view`
6
+ * for the active route on each change — debounced (rapid transitions collapse to
7
+ * the final screen) and deduped (re-renders that don't change the screen are
8
+ * ignored). The event flows through the normal event pipeline, so it carries the
9
+ * active deep-link attribution context + session.
10
+ *
11
+ * Fully guarded: a missing/malformed `navigationRef` (or an app that doesn't use
12
+ * react-navigation) is a no-op, never a crash. The ref is typed structurally so
13
+ * there is no compile-time dependency on `@react-navigation/native`.
14
+ */
15
+
16
+ import type { NavigationContainerRefLike, NavigationRouteLike } from '../models/config';
17
+ import { logger } from '../logger';
18
+
19
+ /** Emit function the tracker calls — bound to the SDK's `trackEvent`. */
20
+ export type ScreenEventEmitter = (
21
+ name: string,
22
+ properties: Record<string, unknown>,
23
+ ) => void;
24
+
25
+ export interface NavigationTrackerOptions {
26
+ /** Collapse rapid transitions; emit only the settled screen. Default 350ms. */
27
+ debounceMs?: number;
28
+ /**
29
+ * Explicit allow-list of route param keys whose primitive values may be
30
+ * captured. Omitted/empty = capture NO params (screen name only) — the
31
+ * privacy-safe default.
32
+ */
33
+ captureParams?: string[];
34
+ }
35
+
36
+ const DEFAULT_DEBOUNCE_MS = 350;
37
+ const SCREEN_VIEW_EVENT = 'screen_view';
38
+ const MAX_PARAM_STRING_LENGTH = 256;
39
+
40
+ /**
41
+ * Reduce route params to a safe, flat set limited to an explicit allow-list of
42
+ * keys: only allow-listed keys are considered, and only primitive values
43
+ * survive (strings capped, nested objects/arrays/functions dropped). Returns
44
+ * undefined when nothing survives (including an empty/omitted allow-list).
45
+ */
46
+ export function sanitizeScreenParams(
47
+ params: Record<string, unknown> | undefined,
48
+ allowList: string[] | undefined,
49
+ ): Record<string, string | number | boolean> | undefined {
50
+ if (!params || typeof params !== 'object' || !allowList || allowList.length === 0) {
51
+ return undefined;
52
+ }
53
+
54
+ const allowed = new Set(allowList);
55
+ const out: Record<string, string | number | boolean> = {};
56
+ for (const [key, value] of Object.entries(params)) {
57
+ if (!allowed.has(key)) continue;
58
+ if (typeof value === 'string') {
59
+ out[key] =
60
+ value.length > MAX_PARAM_STRING_LENGTH ? value.slice(0, MAX_PARAM_STRING_LENGTH) : value;
61
+ } else if (typeof value === 'number' || typeof value === 'boolean') {
62
+ out[key] = value;
63
+ }
64
+ }
65
+
66
+ return Object.keys(out).length > 0 ? out : undefined;
67
+ }
68
+
69
+ export class NavigationTracker {
70
+ private readonly navigationRef: NavigationContainerRefLike;
71
+ private readonly emit: ScreenEventEmitter;
72
+ private readonly debounceMs: number;
73
+ private readonly captureParams: string[] | undefined;
74
+
75
+ private unsubscribe: (() => void) | null = null;
76
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
77
+ private lastScreen: string | null = null;
78
+ private started = false;
79
+
80
+ constructor(
81
+ navigationRef: NavigationContainerRefLike,
82
+ emit: ScreenEventEmitter,
83
+ options: NavigationTrackerOptions = {},
84
+ ) {
85
+ this.navigationRef = navigationRef;
86
+ this.emit = emit;
87
+ this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
88
+ this.captureParams = options.captureParams;
89
+ }
90
+
91
+ /** Subscribe to navigation changes. No-op if the ref is malformed. Idempotent. */
92
+ start(): void {
93
+ if (this.started) return;
94
+
95
+ if (
96
+ !this.navigationRef ||
97
+ typeof this.navigationRef.addListener !== 'function' ||
98
+ typeof this.navigationRef.getCurrentRoute !== 'function'
99
+ ) {
100
+ logger.warn(
101
+ 'autoTrackNavigation enabled but navigationRef is missing or not a React Navigation container ref — screen tracking is disabled.',
102
+ );
103
+ return;
104
+ }
105
+
106
+ try {
107
+ const listener = this.navigationRef.addListener('state', () => {
108
+ this.scheduleCapture();
109
+ });
110
+
111
+ if (typeof listener === 'function') {
112
+ this.unsubscribe = listener;
113
+ } else if (listener && typeof listener.remove === 'function') {
114
+ this.unsubscribe = () => listener.remove?.();
115
+ } else {
116
+ this.unsubscribe = null;
117
+ }
118
+
119
+ this.started = true;
120
+
121
+ const ready =
122
+ typeof this.navigationRef.isReady === 'function' ? this.navigationRef.isReady() : true;
123
+ if (ready) {
124
+ this.captureCurrentScreen();
125
+ }
126
+
127
+ logger.log('Navigation tracking started');
128
+ } catch (e) {
129
+ logger.warn('Failed to start navigation tracking:', e);
130
+ }
131
+ }
132
+
133
+ /** Unsubscribe and clear any pending debounce. Idempotent. */
134
+ stop(): void {
135
+ if (this.debounceTimer) {
136
+ clearTimeout(this.debounceTimer);
137
+ this.debounceTimer = null;
138
+ }
139
+ if (this.unsubscribe) {
140
+ try {
141
+ this.unsubscribe();
142
+ } catch {
143
+ // ignore teardown errors
144
+ }
145
+ this.unsubscribe = null;
146
+ }
147
+ this.started = false;
148
+ this.lastScreen = null;
149
+ }
150
+
151
+ private scheduleCapture(): void {
152
+ if (this.debounceTimer) {
153
+ clearTimeout(this.debounceTimer);
154
+ }
155
+ this.debounceTimer = setTimeout(() => {
156
+ this.debounceTimer = null;
157
+ this.captureCurrentScreen();
158
+ }, this.debounceMs);
159
+ }
160
+
161
+ private captureCurrentScreen(): void {
162
+ let route: NavigationRouteLike | undefined;
163
+ try {
164
+ route = this.navigationRef.getCurrentRoute();
165
+ } catch (e) {
166
+ logger.warn('getCurrentRoute failed:', e);
167
+ return;
168
+ }
169
+
170
+ if (!route || !route.name) return;
171
+
172
+ // Dedupe: ignore state changes that don't move to a different screen.
173
+ if (route.name === this.lastScreen) return;
174
+
175
+ const previousScreen = this.lastScreen;
176
+ this.lastScreen = route.name;
177
+
178
+ const properties: Record<string, unknown> = { screen: route.name };
179
+ if (previousScreen) {
180
+ properties.previousScreen = previousScreen;
181
+ }
182
+ if (this.captureParams && this.captureParams.length > 0) {
183
+ const params = sanitizeScreenParams(route.params, this.captureParams);
184
+ if (params) {
185
+ properties.params = params;
186
+ }
187
+ }
188
+
189
+ try {
190
+ this.emit(SCREEN_VIEW_EVENT, properties);
191
+ } catch (e) {
192
+ logger.warn('Failed to emit screen_view:', e);
193
+ }
194
+ }
195
+ }
@@ -0,0 +1,111 @@
1
+ import { LinkFortyError } from '../errors/linkforty-error';
2
+ import { logger } from '../logger';
3
+ import { SDK_NAME, SDK_VERSION } from '../version';
4
+
5
+ export interface NetworkManagerProtocol {
6
+ request<T>(endpoint: string, options?: RequestInit): Promise<T>;
7
+ }
8
+
9
+ export class NetworkManager implements NetworkManagerProtocol {
10
+ private readonly baseUrl: string;
11
+ private readonly apiKey: string | undefined;
12
+ private readonly maxRetries = 3;
13
+
14
+ constructor(baseUrl: string, apiKey?: string) {
15
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
16
+ this.apiKey = apiKey;
17
+ }
18
+
19
+ async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
20
+ let lastError: Error | undefined;
21
+
22
+ for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
23
+ try {
24
+ return await this.performRequest<T>(endpoint, options);
25
+ } catch (e) {
26
+ lastError = e as Error;
27
+
28
+ // Don't retry on client errors (4xx) or non-retryable errors
29
+ if (e instanceof LinkFortyError) {
30
+ if (
31
+ e.code === 'INVALID_RESPONSE' &&
32
+ e.message.match(/status: (\d+)/)
33
+ ) {
34
+ const statusMatch = e.message.match(/status: (\d+)/);
35
+ if (statusMatch) {
36
+ const status = parseInt(statusMatch[1], 10);
37
+ if (status >= 400 && status < 500) throw e;
38
+ }
39
+ }
40
+ if (
41
+ e.code === 'INVALID_CONFIGURATION' ||
42
+ e.code === 'DECODING_ERROR'
43
+ ) {
44
+ throw e;
45
+ }
46
+ }
47
+
48
+ // Exponential backoff: 1s, 2s, 4s
49
+ if (attempt < this.maxRetries) {
50
+ const delayMs = Math.pow(2, attempt - 1) * 1000;
51
+ logger.log(
52
+ `Request failed (attempt ${attempt}/${this.maxRetries}), retrying in ${delayMs / 1000}s...`,
53
+ );
54
+ await this.delay(delayMs);
55
+ }
56
+ }
57
+ }
58
+
59
+ throw lastError instanceof LinkFortyError
60
+ ? lastError
61
+ : LinkFortyError.networkError(
62
+ lastError ?? new Error(`Request failed after ${this.maxRetries} attempts`),
63
+ );
64
+ }
65
+
66
+ private async performRequest<T>(endpoint: string, options: RequestInit): Promise<T> {
67
+ const url = `${this.baseUrl}${endpoint}`;
68
+
69
+ const headers: Record<string, string> = {
70
+ 'Content-Type': 'application/json',
71
+ // SDK identity header (SIT-235) — name/version on every SDK request.
72
+ 'X-LinkForty-SDK': `${SDK_NAME}/${SDK_VERSION}`,
73
+ ...(options.headers as Record<string, string> | undefined),
74
+ };
75
+
76
+ if (this.apiKey) {
77
+ headers['Authorization'] = `Bearer ${this.apiKey}`;
78
+ }
79
+
80
+ let response: Response;
81
+ try {
82
+ response = await fetch(url, {
83
+ ...options,
84
+ headers,
85
+ });
86
+ } catch (e) {
87
+ throw LinkFortyError.networkError(e as Error);
88
+ }
89
+
90
+ if (!response.ok) {
91
+ let message: string | undefined;
92
+ try {
93
+ const body = await response.json();
94
+ message = body.message || body.error;
95
+ } catch {
96
+ // ignore parse failure
97
+ }
98
+ throw LinkFortyError.invalidResponse(response.status, message);
99
+ }
100
+
101
+ try {
102
+ return (await response.json()) as T;
103
+ } catch (e) {
104
+ throw LinkFortyError.decodingError(e as Error);
105
+ }
106
+ }
107
+
108
+ private delay(ms: number): Promise<void> {
109
+ return new Promise((resolve) => setTimeout(resolve, ms));
110
+ }
111
+ }
@@ -0,0 +1,7 @@
1
+ export const STORAGE_KEYS = {
2
+ INSTALL_ID: '@linkforty:install_id',
3
+ INSTALL_DATA: '@linkforty:install_data',
4
+ FIRST_LAUNCH: '@linkforty:first_launch',
5
+ EVENT_QUEUE: '@linkforty:event_queue',
6
+ ATTRIBUTION: '@linkforty:attribution',
7
+ } as const;
@@ -0,0 +1,61 @@
1
+ import AsyncStorage from '@react-native-async-storage/async-storage';
2
+ import type { DeepLinkData } from '../models/deep-link-data';
3
+ import { logger } from '../logger';
4
+ import { STORAGE_KEYS } from './storage-keys';
5
+
6
+ export interface StorageManagerProtocol {
7
+ saveInstallId(installId: string): Promise<void>;
8
+ getInstallId(): Promise<string | null>;
9
+ saveInstallData(data: DeepLinkData): Promise<void>;
10
+ getInstallData(): Promise<DeepLinkData | null>;
11
+ isFirstLaunch(): Promise<boolean>;
12
+ setHasLaunched(): Promise<void>;
13
+ clearAll(): Promise<void>;
14
+ }
15
+
16
+ export class StorageManager implements StorageManagerProtocol {
17
+ async saveInstallId(installId: string): Promise<void> {
18
+ await AsyncStorage.setItem(STORAGE_KEYS.INSTALL_ID, installId);
19
+ }
20
+
21
+ async getInstallId(): Promise<string | null> {
22
+ return AsyncStorage.getItem(STORAGE_KEYS.INSTALL_ID);
23
+ }
24
+
25
+ async saveInstallData(data: DeepLinkData): Promise<void> {
26
+ try {
27
+ await AsyncStorage.setItem(STORAGE_KEYS.INSTALL_DATA, JSON.stringify(data));
28
+ } catch (e) {
29
+ logger.error('Failed to encode install data:', e);
30
+ }
31
+ }
32
+
33
+ async getInstallData(): Promise<DeepLinkData | null> {
34
+ try {
35
+ const json = await AsyncStorage.getItem(STORAGE_KEYS.INSTALL_DATA);
36
+ if (!json) return null;
37
+ return JSON.parse(json) as DeepLinkData;
38
+ } catch (e) {
39
+ logger.error('Failed to decode install data:', e);
40
+ return null;
41
+ }
42
+ }
43
+
44
+ async isFirstLaunch(): Promise<boolean> {
45
+ const flag = await AsyncStorage.getItem(STORAGE_KEYS.FIRST_LAUNCH);
46
+ return flag === null;
47
+ }
48
+
49
+ async setHasLaunched(): Promise<void> {
50
+ await AsyncStorage.setItem(STORAGE_KEYS.FIRST_LAUNCH, 'true');
51
+ }
52
+
53
+ async clearAll(): Promise<void> {
54
+ await AsyncStorage.multiRemove([
55
+ STORAGE_KEYS.INSTALL_ID,
56
+ STORAGE_KEYS.INSTALL_DATA,
57
+ STORAGE_KEYS.FIRST_LAUNCH,
58
+ STORAGE_KEYS.EVENT_QUEUE,
59
+ ]);
60
+ }
61
+ }
package/src/version.ts ADDED
@@ -0,0 +1,3 @@
1
+ // AUTO-GENERATED at build from package.json by scripts/gen-version.mjs — do not edit by hand.
2
+ export const SDK_NAME = 'expo';
3
+ export const SDK_VERSION = '1.0.0';