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,171 @@
1
+ import * as Linking from 'expo-linking';
2
+ import type { FingerprintCollectorProtocol } from '../fingerprint/fingerprint-collector';
3
+ import type { NetworkManagerProtocol } from '../network/network-manager';
4
+ import type { DeepLinkData } from '../models/deep-link-data';
5
+ import { parseDeepLinkUrl, parseUrlString, buildQueryString } from './url-parser';
6
+ import { logger } from '../logger';
7
+
8
+ export type DeferredDeepLinkCallback = (deepLinkData: DeepLinkData | null) => void;
9
+ export type DeepLinkCallback = (url: string, deepLinkData: DeepLinkData | null) => void;
10
+
11
+ export class DeepLinkHandler {
12
+ private baseUrl: string;
13
+ private network: NetworkManagerProtocol;
14
+ private fingerprint: FingerprintCollectorProtocol;
15
+ private linkPathPrefix: string | undefined;
16
+
17
+ private deferredCallbacks: DeferredDeepLinkCallback[] = [];
18
+ private deepLinkCallbacks: DeepLinkCallback[] = [];
19
+ private deferredDelivered = false;
20
+ private cachedDeferredData: DeepLinkData | null = null;
21
+ private subscription: { remove(): void } | null = null;
22
+
23
+ constructor(
24
+ baseUrl: string,
25
+ network: NetworkManagerProtocol,
26
+ fingerprint: FingerprintCollectorProtocol,
27
+ linkPathPrefix?: string,
28
+ ) {
29
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
30
+ this.network = network;
31
+ this.fingerprint = fingerprint;
32
+ this.linkPathPrefix = linkPathPrefix?.replace(/^\/+|\/+$/g, '') || undefined;
33
+ }
34
+
35
+ /**
36
+ * Start listening for deep links via expo-linking.
37
+ */
38
+ startListening(): void {
39
+ this.subscription = Linking.addEventListener('url', (event: { url: string }) => {
40
+ this.handleUrl(event.url);
41
+ });
42
+
43
+ // Check if app was opened via deep link
44
+ Linking.getInitialURL()
45
+ .then((url) => {
46
+ if (url) {
47
+ this.handleUrl(url);
48
+ }
49
+ })
50
+ .catch((e) => {
51
+ logger.warn('Failed to get initial URL:', e);
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Manually pass a deep link URL.
57
+ */
58
+ handleDeepLink(url: string): void {
59
+ this.handleUrl(url);
60
+ }
61
+
62
+ /**
63
+ * Register a deferred deep link callback.
64
+ * If data was already delivered, calls back immediately.
65
+ */
66
+ onDeferredDeepLink(callback: DeferredDeepLinkCallback): void {
67
+ this.deferredCallbacks.push(callback);
68
+
69
+ if (this.deferredDelivered) {
70
+ callback(this.cachedDeferredData);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Register a direct deep link callback. Multiple callbacks supported.
76
+ */
77
+ onDeepLink(callback: DeepLinkCallback): void {
78
+ this.deepLinkCallbacks.push(callback);
79
+ }
80
+
81
+ /**
82
+ * Deliver deferred deep link data (called by SDK after install attribution).
83
+ */
84
+ deliverDeferredDeepLink(data: DeepLinkData | null): void {
85
+ this.cachedDeferredData = data;
86
+ this.deferredDelivered = true;
87
+ logger.log('Delivering deferred deep link:', data?.shortCode ?? 'organic');
88
+
89
+ for (const cb of this.deferredCallbacks) {
90
+ cb(data);
91
+ }
92
+ }
93
+
94
+ clearCallbacks(): void {
95
+ this.deferredCallbacks = [];
96
+ this.deepLinkCallbacks = [];
97
+ this.deferredDelivered = false;
98
+ this.cachedDeferredData = null;
99
+ }
100
+
101
+ cleanup(): void {
102
+ this.subscription?.remove();
103
+ this.subscription = null;
104
+ this.clearCallbacks();
105
+ }
106
+
107
+ private handleUrl(url: string): void {
108
+ if (!url || this.deepLinkCallbacks.length === 0) return;
109
+
110
+ logger.log('Handling deep link:', url);
111
+
112
+ // Parse locally first as fallback
113
+ const localData = parseDeepLinkUrl(url, this.baseUrl);
114
+
115
+ // If this is a LinkForty URL, try server-side resolution
116
+ if (localData && url.startsWith(this.baseUrl)) {
117
+ this.resolveUrl(url)
118
+ .then((resolvedData) => {
119
+ const data = resolvedData ?? localData;
120
+ for (const cb of this.deepLinkCallbacks) cb(url, data);
121
+ })
122
+ .catch(() => {
123
+ for (const cb of this.deepLinkCallbacks) cb(url, localData);
124
+ });
125
+ } else {
126
+ for (const cb of this.deepLinkCallbacks) cb(url, localData);
127
+ }
128
+ }
129
+
130
+ private async resolveUrl(url: string): Promise<DeepLinkData | null> {
131
+ const parsed = parseUrlString(url);
132
+ if (!parsed) return null;
133
+
134
+ const pathSegments = parsed.pathname.split('/').filter(Boolean);
135
+ if (pathSegments.length === 0) return null;
136
+
137
+ let resolvePath: string;
138
+ if (this.linkPathPrefix && pathSegments[0] === this.linkPathPrefix && pathSegments[1]) {
139
+ resolvePath = `/api/sdk/v1/resolve/${pathSegments[0]}/${pathSegments[1]}`;
140
+ } else if (pathSegments.length >= 2) {
141
+ resolvePath = `/api/sdk/v1/resolve/${pathSegments[0]}/${pathSegments[1]}`;
142
+ } else if (this.linkPathPrefix) {
143
+ resolvePath = `/api/sdk/v1/resolve/${this.linkPathPrefix}/${pathSegments[0]}`;
144
+ } else {
145
+ resolvePath = `/api/sdk/v1/resolve/${pathSegments[0]}`;
146
+ }
147
+
148
+ // Collect fingerprint for click attribution
149
+ try {
150
+ const fp = this.fingerprint.collect(168);
151
+ const fpParams: Record<string, string> = {
152
+ fp_tz: fp.timezone,
153
+ fp_lang: fp.language,
154
+ fp_sw: String(fp.screenWidth),
155
+ fp_sh: String(fp.screenHeight),
156
+ fp_platform: fp.platform,
157
+ fp_pv: fp.platformVersion,
158
+ };
159
+ resolvePath += `?${buildQueryString(fpParams)}`;
160
+ } catch (e) {
161
+ logger.warn('Fingerprint collection failed, resolving without fingerprint:', e);
162
+ }
163
+
164
+ try {
165
+ return await this.network.request<DeepLinkData>(resolvePath);
166
+ } catch (e) {
167
+ logger.warn('Server-side resolution failed:', e);
168
+ return null;
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,96 @@
1
+ import type { DeepLinkData } from '../models/deep-link-data';
2
+ import type { UTMParameters } from '../models/utm-parameters';
3
+
4
+ interface ParsedUrl {
5
+ pathname: string;
6
+ searchParams: Map<string, string>;
7
+ }
8
+
9
+ /**
10
+ * Hermes-safe URL parsing (URL.pathname may throw "not implemented" in Hermes).
11
+ */
12
+ export function parseUrlString(url: string): ParsedUrl | null {
13
+ try {
14
+ const protocolEnd = url.indexOf('://');
15
+ if (protocolEnd === -1) return null;
16
+
17
+ const afterProtocol = url.substring(protocolEnd + 3);
18
+ const pathStart = afterProtocol.indexOf('/');
19
+ const pathAndQuery = pathStart === -1 ? '/' : afterProtocol.substring(pathStart);
20
+
21
+ const hashIndex = pathAndQuery.indexOf('#');
22
+ const withoutHash = hashIndex === -1 ? pathAndQuery : pathAndQuery.substring(0, hashIndex);
23
+
24
+ const queryStart = withoutHash.indexOf('?');
25
+ const pathname = queryStart === -1 ? withoutHash : withoutHash.substring(0, queryStart);
26
+ const queryString = queryStart === -1 ? '' : withoutHash.substring(queryStart + 1);
27
+
28
+ const searchParams = new Map<string, string>();
29
+ if (queryString) {
30
+ for (const pair of queryString.split('&')) {
31
+ const eqIndex = pair.indexOf('=');
32
+ if (eqIndex === -1) {
33
+ searchParams.set(decodeURIComponent(pair), '');
34
+ } else {
35
+ searchParams.set(
36
+ decodeURIComponent(pair.substring(0, eqIndex)),
37
+ decodeURIComponent(pair.substring(eqIndex + 1)),
38
+ );
39
+ }
40
+ }
41
+ }
42
+
43
+ return { pathname, searchParams };
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export function buildQueryString(params: Record<string, string>): string {
50
+ return Object.entries(params)
51
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
52
+ .join('&');
53
+ }
54
+
55
+ const UTM_KEYS = new Set(['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']);
56
+
57
+ export function parseDeepLinkUrl(url: string, baseUrl?: string): DeepLinkData | null {
58
+ // If baseUrl is set, only parse URLs matching it
59
+ if (baseUrl && !url.startsWith(baseUrl)) {
60
+ return null;
61
+ }
62
+
63
+ const parsed = parseUrlString(url);
64
+ if (!parsed) return null;
65
+
66
+ const pathSegments = parsed.pathname.split('/').filter(Boolean);
67
+ const shortCode = pathSegments[pathSegments.length - 1];
68
+ if (!shortCode) return null;
69
+
70
+ // Extract UTM parameters
71
+ const utmParameters: UTMParameters = {};
72
+ let hasUtm = false;
73
+ for (const [key, value] of parsed.searchParams) {
74
+ if (key === 'utm_source') { utmParameters.source = value; hasUtm = true; }
75
+ else if (key === 'utm_medium') { utmParameters.medium = value; hasUtm = true; }
76
+ else if (key === 'utm_campaign') { utmParameters.campaign = value; hasUtm = true; }
77
+ else if (key === 'utm_term') { utmParameters.term = value; hasUtm = true; }
78
+ else if (key === 'utm_content') { utmParameters.content = value; hasUtm = true; }
79
+ }
80
+
81
+ // Extract custom (non-UTM) parameters
82
+ const customParameters: Record<string, string> = {};
83
+ let hasCustom = false;
84
+ for (const [key, value] of parsed.searchParams) {
85
+ if (!UTM_KEYS.has(key)) {
86
+ customParameters[key] = value;
87
+ hasCustom = true;
88
+ }
89
+ }
90
+
91
+ return {
92
+ shortCode,
93
+ utmParameters: hasUtm ? utmParameters : undefined,
94
+ customParameters: hasCustom ? customParameters : undefined,
95
+ };
96
+ }
@@ -0,0 +1,89 @@
1
+ export enum LinkFortyErrorCode {
2
+ NOT_INITIALIZED = 'NOT_INITIALIZED',
3
+ ALREADY_INITIALIZED = 'ALREADY_INITIALIZED',
4
+ INVALID_CONFIGURATION = 'INVALID_CONFIGURATION',
5
+ NETWORK_ERROR = 'NETWORK_ERROR',
6
+ INVALID_RESPONSE = 'INVALID_RESPONSE',
7
+ DECODING_ERROR = 'DECODING_ERROR',
8
+ INVALID_EVENT_DATA = 'INVALID_EVENT_DATA',
9
+ INVALID_DEEP_LINK_URL = 'INVALID_DEEP_LINK_URL',
10
+ MISSING_API_KEY = 'MISSING_API_KEY',
11
+ }
12
+
13
+ export class LinkFortyError extends Error {
14
+ readonly code: LinkFortyErrorCode;
15
+ readonly cause?: Error;
16
+
17
+ constructor(code: LinkFortyErrorCode, message: string, cause?: Error) {
18
+ super(message);
19
+ this.name = 'LinkFortyError';
20
+ this.code = code;
21
+ this.cause = cause;
22
+ }
23
+
24
+ static notInitialized(): LinkFortyError {
25
+ return new LinkFortyError(
26
+ LinkFortyErrorCode.NOT_INITIALIZED,
27
+ 'LinkForty SDK is not initialized. Call initialize() first.',
28
+ );
29
+ }
30
+
31
+ static alreadyInitialized(): LinkFortyError {
32
+ return new LinkFortyError(
33
+ LinkFortyErrorCode.ALREADY_INITIALIZED,
34
+ 'LinkForty SDK has already been initialized.',
35
+ );
36
+ }
37
+
38
+ static invalidConfiguration(detail: string): LinkFortyError {
39
+ return new LinkFortyError(
40
+ LinkFortyErrorCode.INVALID_CONFIGURATION,
41
+ `Invalid configuration: ${detail}`,
42
+ );
43
+ }
44
+
45
+ static networkError(cause: Error): LinkFortyError {
46
+ return new LinkFortyError(
47
+ LinkFortyErrorCode.NETWORK_ERROR,
48
+ `Network error: ${cause.message}`,
49
+ cause,
50
+ );
51
+ }
52
+
53
+ static invalidResponse(statusCode: number, message?: string): LinkFortyError {
54
+ const detail = message ? `: ${message}` : '';
55
+ return new LinkFortyError(
56
+ LinkFortyErrorCode.INVALID_RESPONSE,
57
+ `Invalid server response (status: ${statusCode})${detail}`,
58
+ );
59
+ }
60
+
61
+ static decodingError(cause: Error): LinkFortyError {
62
+ return new LinkFortyError(
63
+ LinkFortyErrorCode.DECODING_ERROR,
64
+ `Failed to decode response: ${cause.message}`,
65
+ cause,
66
+ );
67
+ }
68
+
69
+ static invalidEventData(detail: string): LinkFortyError {
70
+ return new LinkFortyError(
71
+ LinkFortyErrorCode.INVALID_EVENT_DATA,
72
+ `Invalid event data: ${detail}`,
73
+ );
74
+ }
75
+
76
+ static invalidDeepLinkUrl(detail: string): LinkFortyError {
77
+ return new LinkFortyError(
78
+ LinkFortyErrorCode.INVALID_DEEP_LINK_URL,
79
+ `Invalid deep link URL: ${detail}`,
80
+ );
81
+ }
82
+
83
+ static missingApiKey(): LinkFortyError {
84
+ return new LinkFortyError(
85
+ LinkFortyErrorCode.MISSING_API_KEY,
86
+ 'API key is required for this operation. Provide an apiKey in the config.',
87
+ );
88
+ }
89
+ }
@@ -0,0 +1,75 @@
1
+ import AsyncStorage from '@react-native-async-storage/async-storage';
2
+ import type { EventRequest } from '../models/event-request';
3
+ import { STORAGE_KEYS } from '../storage/storage-keys';
4
+ import { logger } from '../logger';
5
+
6
+ const MAX_QUEUE_SIZE = 100;
7
+
8
+ export class EventQueue {
9
+ private queue: EventRequest[] = [];
10
+ private loaded = false;
11
+
12
+ async load(): Promise<void> {
13
+ if (this.loaded) return;
14
+ try {
15
+ const json = await AsyncStorage.getItem(STORAGE_KEYS.EVENT_QUEUE);
16
+ if (json) {
17
+ this.queue = JSON.parse(json) as EventRequest[];
18
+ }
19
+ } catch (e) {
20
+ logger.error('Failed to load event queue:', e);
21
+ this.queue = [];
22
+ }
23
+ this.loaded = true;
24
+ }
25
+
26
+ async enqueue(event: EventRequest): Promise<boolean> {
27
+ await this.load();
28
+ if (this.queue.length >= MAX_QUEUE_SIZE) {
29
+ logger.log('Event queue full, dropping event:', event.eventName);
30
+ return false;
31
+ }
32
+ this.queue.push(event);
33
+ await this.persist();
34
+ logger.log(`Event queued: ${event.eventName} (queue size: ${this.queue.length})`);
35
+ return true;
36
+ }
37
+
38
+ async dequeue(): Promise<EventRequest | null> {
39
+ await this.load();
40
+ if (this.queue.length === 0) return null;
41
+ const event = this.queue.shift()!;
42
+ await this.persist();
43
+ return event;
44
+ }
45
+
46
+ async peek(): Promise<EventRequest[]> {
47
+ await this.load();
48
+ return [...this.queue];
49
+ }
50
+
51
+ async clear(): Promise<void> {
52
+ const size = this.queue.length;
53
+ this.queue = [];
54
+ await this.persist();
55
+ if (size > 0) {
56
+ logger.log(`Event queue cleared (${size} events removed)`);
57
+ }
58
+ }
59
+
60
+ get count(): number {
61
+ return this.queue.length;
62
+ }
63
+
64
+ get isEmpty(): boolean {
65
+ return this.queue.length === 0;
66
+ }
67
+
68
+ private async persist(): Promise<void> {
69
+ try {
70
+ await AsyncStorage.setItem(STORAGE_KEYS.EVENT_QUEUE, JSON.stringify(this.queue));
71
+ } catch (e) {
72
+ logger.error('Failed to persist event queue:', e);
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,118 @@
1
+ import type { NetworkManagerProtocol } from '../network/network-manager';
2
+ import type { StorageManagerProtocol } from '../storage/storage-manager';
3
+ import type { EventRequest } from '../models/event-request';
4
+ import type { AttributionContext } from '../attribution/attribution-context';
5
+ import { EventQueue } from './event-queue';
6
+ import { LinkFortyError } from '../errors/linkforty-error';
7
+ import { logger } from '../logger';
8
+ import { SDK_NAME, SDK_VERSION } from '../version';
9
+
10
+ export class EventTracker {
11
+ private readonly network: NetworkManagerProtocol;
12
+ private readonly storage: StorageManagerProtocol;
13
+ private readonly queue: EventQueue;
14
+ private readonly attribution?: AttributionContext;
15
+
16
+ constructor(
17
+ network: NetworkManagerProtocol,
18
+ storage: StorageManagerProtocol,
19
+ queue?: EventQueue,
20
+ attribution?: AttributionContext,
21
+ ) {
22
+ this.network = network;
23
+ this.storage = storage;
24
+ this.queue = queue ?? new EventQueue();
25
+ this.attribution = attribution;
26
+ }
27
+
28
+ async trackEvent(name: string, properties?: Record<string, unknown>): Promise<void> {
29
+ if (!name || !name.trim()) {
30
+ throw LinkFortyError.invalidEventData('Event name cannot be empty');
31
+ }
32
+
33
+ const installId = await this.storage.getInstallId();
34
+ if (!installId) {
35
+ throw LinkFortyError.notInitialized();
36
+ }
37
+
38
+ // Stamp the event with the active last-click attribution + session so it can
39
+ // be credited to the originating deep link. Stamped at event time, so a
40
+ // queued event keeps its point-in-time attribution.
41
+ const event: EventRequest = {
42
+ installId,
43
+ eventName: name,
44
+ eventData: properties ?? {},
45
+ timestamp: new Date().toISOString(),
46
+ ...this.attribution?.getStamp(),
47
+ // SDK identity for health/version diagnostics (SIT-235)
48
+ sdkName: SDK_NAME,
49
+ sdkVersion: SDK_VERSION,
50
+ };
51
+
52
+ try {
53
+ await this.sendEvent(event);
54
+ logger.log('Event tracked:', name);
55
+
56
+ // If send succeeds, try to flush queued events
57
+ await this.flushQueue();
58
+ } catch (e) {
59
+ // Queue the event on failure
60
+ await this.queue.enqueue(event);
61
+ logger.log('Event queued due to error:', e);
62
+ throw e;
63
+ }
64
+ }
65
+
66
+ async trackRevenue(
67
+ amount: number,
68
+ currency: string,
69
+ properties?: Record<string, unknown>,
70
+ ): Promise<void> {
71
+ if (amount < 0) {
72
+ throw LinkFortyError.invalidEventData('Revenue amount must be non-negative');
73
+ }
74
+
75
+ const eventProperties: Record<string, unknown> = {
76
+ ...properties,
77
+ revenue: amount,
78
+ currency,
79
+ };
80
+
81
+ await this.trackEvent('revenue', eventProperties);
82
+ }
83
+
84
+ async flushQueue(): Promise<void> {
85
+ await this.queue.load();
86
+ logger.log(`Flushing event queue (${this.queue.count} events)`);
87
+
88
+ while (!this.queue.isEmpty) {
89
+ const event = await this.queue.dequeue();
90
+ if (!event) break;
91
+
92
+ try {
93
+ await this.sendEvent(event);
94
+ logger.log('Queued event sent:', event.eventName);
95
+ } catch (e) {
96
+ // Re-queue the event and stop flushing
97
+ await this.queue.enqueue(event);
98
+ logger.log('Failed to send queued event:', e);
99
+ return;
100
+ }
101
+ }
102
+ }
103
+
104
+ async clearQueue(): Promise<void> {
105
+ await this.queue.clear();
106
+ }
107
+
108
+ get queuedEventCount(): number {
109
+ return this.queue.count;
110
+ }
111
+
112
+ private async sendEvent(event: EventRequest): Promise<void> {
113
+ await this.network.request('/api/sdk/v1/event', {
114
+ method: 'POST',
115
+ body: JSON.stringify(event),
116
+ });
117
+ }
118
+ }
@@ -0,0 +1,35 @@
1
+ import * as Device from 'expo-device';
2
+ import * as Application from 'expo-application';
3
+ import { getLocales, getCalendars } from 'expo-localization';
4
+ import { Platform, Dimensions } from 'react-native';
5
+ import type { DeviceFingerprint } from '../models/device-fingerprint';
6
+
7
+ export interface FingerprintCollectorProtocol {
8
+ collect(attributionWindowHours: number, deviceId?: string): DeviceFingerprint;
9
+ }
10
+
11
+ export class FingerprintCollector implements FingerprintCollectorProtocol {
12
+ collect(attributionWindowHours: number, deviceId?: string): DeviceFingerprint {
13
+ const { width, height } = Dimensions.get('window');
14
+ const locales = getLocales();
15
+ const calendars = getCalendars();
16
+
17
+ const appName = Application.applicationName ?? 'App';
18
+ const appVersion = Application.nativeApplicationVersion ?? '1.0.0';
19
+ const platform = Platform.OS;
20
+ const platformVersion = Device.osVersion ?? '0';
21
+
22
+ return {
23
+ userAgent: `${appName}/${appVersion} ${platform}/${platformVersion}`,
24
+ timezone: calendars[0]?.timeZone ?? 'UTC',
25
+ language: locales[0]?.languageTag ?? 'en-US',
26
+ screenWidth: Math.round(width),
27
+ screenHeight: Math.round(height),
28
+ platform,
29
+ platformVersion,
30
+ appVersion,
31
+ deviceId,
32
+ attributionWindowHours,
33
+ };
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ export { default } from './linkforty-sdk';
2
+ export { default as DeepLink } from './linkforty-sdk';
3
+ export { LinkFortySDK } from './linkforty-sdk';
4
+
5
+ export { LinkFortyError, LinkFortyErrorCode } from './errors/linkforty-error';
6
+
7
+ export type {
8
+ LinkFortyConfig,
9
+ AutoTrackNavigationOptions,
10
+ NavigationRouteLike,
11
+ NavigationContainerRefLike,
12
+ } from './models/config';
13
+ export type { DeepLinkData } from './models/deep-link-data';
14
+ export type { InstallAttributionResponse } from './models/install-response';
15
+ export type { UTMParameters } from './models/utm-parameters';
16
+ export type { DeviceFingerprint } from './models/device-fingerprint';
17
+ export type { CreateLinkOptions } from './models/create-link-options';
18
+ export type { CreateLinkResult } from './models/create-link-result';
19
+ export type { EventRequest } from './models/event-request';
20
+ export type { ActiveAttribution, AttributionStamp } from './models/attribution';
21
+
22
+ export { AttributionContext } from './attribution/attribution-context';
23
+ export { NavigationTracker, sanitizeScreenParams } from './navigation/navigation-tracker';
24
+ export type {
25
+ ScreenEventEmitter,
26
+ NavigationTrackerOptions,
27
+ } from './navigation/navigation-tracker';
28
+
29
+ export type { DeferredDeepLinkCallback, DeepLinkCallback } from './deeplink/deep-link-handler';