pulse-updates 1.2.5 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -4
- package/android/build.gradle +1 -0
- package/android/consumer-rules.pro +21 -0
- package/android/src/main/java/app/pulse/updates/PulseUpdatesPackage.kt +5 -1
- package/android/src/main/java/app/pulse/updates/attribution/DeferredHandoffReferrerParser.kt +47 -0
- package/android/src/main/java/app/pulse/updates/attribution/InstallReferrerReflection.kt +155 -0
- package/android/src/main/java/app/pulse/updates/attribution/PulseAttributionModule.kt +229 -0
- package/ios/PulseAttribution/PulseAttribution.mm +45 -0
- package/ios/PulseAttribution/PulseDeferredLinkPasteButtonComponentView.h +13 -0
- package/ios/PulseAttribution/PulseDeferredLinkPasteButtonComponentView.mm +81 -0
- package/ios/PulseAttribution/PulseDeferredLinkPasteButtonManager.m +28 -0
- package/ios/PulseAttribution/PulseDeferredLinkPasteView.h +21 -0
- package/ios/PulseAttribution/PulseDeferredLinkPasteView.m +231 -0
- package/lib/commonjs/DeferredLinkPasteButton.js +64 -0
- package/lib/commonjs/DeferredLinkPasteButton.js.map +1 -0
- package/lib/commonjs/NativePulseAttribution.js +13 -0
- package/lib/commonjs/NativePulseAttribution.js.map +1 -0
- package/lib/commonjs/PulseDeferredLinkPasteButtonNativeComponent.js +13 -0
- package/lib/commonjs/PulseDeferredLinkPasteButtonNativeComponent.js.map +1 -0
- package/lib/commonjs/index.js +33 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/links.js +944 -0
- package/lib/commonjs/links.js.map +1 -0
- package/lib/commonjs/nativeAttribution.js +29 -0
- package/lib/commonjs/nativeAttribution.js.map +1 -0
- package/lib/module/DeferredLinkPasteButton.js +56 -0
- package/lib/module/DeferredLinkPasteButton.js.map +1 -0
- package/lib/module/NativePulseAttribution.js +9 -0
- package/lib/module/NativePulseAttribution.js.map +1 -0
- package/lib/module/PulseDeferredLinkPasteButtonNativeComponent.js +7 -0
- package/lib/module/PulseDeferredLinkPasteButtonNativeComponent.js.map +1 -0
- package/lib/module/index.js +3 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/links.js +933 -0
- package/lib/module/links.js.map +1 -0
- package/lib/module/nativeAttribution.js +21 -0
- package/lib/module/nativeAttribution.js.map +1 -0
- package/lib/typescript/DeferredLinkPasteButton.d.ts +22 -0
- package/lib/typescript/DeferredLinkPasteButton.d.ts.map +1 -0
- package/lib/typescript/NativePulseAttribution.d.ts +11 -0
- package/lib/typescript/NativePulseAttribution.d.ts.map +1 -0
- package/lib/typescript/PulseDeferredLinkPasteButtonNativeComponent.d.ts +16 -0
- package/lib/typescript/PulseDeferredLinkPasteButtonNativeComponent.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +4 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/links.d.ts +142 -0
- package/lib/typescript/links.d.ts.map +1 -0
- package/lib/typescript/nativeAttribution.d.ts +9 -0
- package/lib/typescript/nativeAttribution.d.ts.map +1 -0
- package/package.json +17 -2
- package/pulse-updates.podspec +8 -2
- package/scripts/publish.mjs +5 -0
- package/src/DeferredLinkPasteButton.tsx +98 -0
- package/src/NativePulseAttribution.ts +12 -0
- package/src/PulseDeferredLinkPasteButtonNativeComponent.ts +18 -0
- package/src/index.ts +4 -0
- package/src/links.ts +1232 -0
- package/src/nativeAttribution.ts +25 -0
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.createDeferredLinkClient = void 0;
|
|
7
|
+
exports.createPulseLinkClient = createPulseLinkClient;
|
|
8
|
+
exports.extractDeferredHandoffToken = extractDeferredHandoffToken;
|
|
9
|
+
exports.isOpaqueDeferredLinkToken = isOpaqueDeferredLinkToken;
|
|
10
|
+
exports.normalizeAndroidInstallReferrerResult = normalizeAndroidInstallReferrerResult;
|
|
11
|
+
exports.normalizeDeferredHandoffToken = normalizeDeferredHandoffToken;
|
|
12
|
+
/**
|
|
13
|
+
* Pulse Links - a headless, app-neutral deferred-link client.
|
|
14
|
+
*
|
|
15
|
+
* Encore owns the phase-one HTTP resolver and attribution ledger. Pulse owns the reusable
|
|
16
|
+
* client boundary: capture, durable pending state, retry/idempotence, safe application and the
|
|
17
|
+
* privacy-preserving first-open request. Billing/account SDKs integrate only through the narrow
|
|
18
|
+
* optional account bridge below.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Implemented by the separate native `PulseAttribution` module. */
|
|
22
|
+
|
|
23
|
+
const OPAQUE_TOKEN = /^[A-Za-z0-9_-]{16,512}$/;
|
|
24
|
+
const PUBLIC_EXPOSURE = /^[a-f0-9]{32}$/i;
|
|
25
|
+
const INSTALL_ATTEMPT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
26
|
+
const APP_IDENTITY = /^[A-Za-z0-9.-]+$/;
|
|
27
|
+
const LOCALE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
28
|
+
const DEFAULT_PREFIXES = ['encore_handoff:', 'pulse_handoff:'];
|
|
29
|
+
const DEFAULT_QUERY_KEYS = new Set(['encore_handoff', 'encore_deferred', 'pulse_handoff', 'pulse_deferred']);
|
|
30
|
+
const MATCH_BASES = new Set(['direct_token', 'android_install_referrer', 'ios_user_paste', 'account_bound', 'unique_probabilistic', 'unmatched']);
|
|
31
|
+
const CAPTURE_BASES = new Set(['direct_token', 'android_install_referrer', 'ios_user_paste']);
|
|
32
|
+
const STATUSES = new Set(['idle', 'pending', 'resolving', 'waiting_for_account', 'retryable_error', 'applied', 'terminal_error']);
|
|
33
|
+
const INSTALL_REFERRER_STATUSES = new Set(['OK', 'NO_TOKEN', 'FEATURE_NOT_SUPPORTED', 'SERVICE_UNAVAILABLE', 'DEVELOPER_ERROR', 'SERVICE_DISCONNECTED']);
|
|
34
|
+
const DEFAULT_ACTIONS = ['open_home', 'open_premium', 'manage_subscription', 'refresh_entitlement'];
|
|
35
|
+
const DEFAULT_SENSITIVE_ACTIONS = ['manage_subscription', 'refresh_entitlement'];
|
|
36
|
+
const DEFAULT_STORAGE_KEY = 'pulse.links.v1';
|
|
37
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
38
|
+
const DEFAULT_RETRY_BASE_MS = 15_000;
|
|
39
|
+
const DEFAULT_RETRY_MAX_MS = 6 * 60 * 60 * 1_000;
|
|
40
|
+
const DEFAULT_TOKEN_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1_000;
|
|
41
|
+
const DEFAULT_RECENT_INSTALL_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
|
|
42
|
+
const MAX_APPLIED_IDS = 32;
|
|
43
|
+
const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
|
|
44
|
+
const MAX_PERSISTED_BYTES = 16_384;
|
|
45
|
+
const emptyState = () => ({
|
|
46
|
+
version: 1,
|
|
47
|
+
status: 'idle',
|
|
48
|
+
pending: null,
|
|
49
|
+
lastAppliedId: null,
|
|
50
|
+
appliedIds: [],
|
|
51
|
+
notifiedOutcomes: [],
|
|
52
|
+
firstOpen: {
|
|
53
|
+
installAttemptId: null,
|
|
54
|
+
completed: false,
|
|
55
|
+
attemptedAt: 0,
|
|
56
|
+
attempts: 0,
|
|
57
|
+
nextRetryAt: 0
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
function isOpaqueDeferredLinkToken(value) {
|
|
61
|
+
return typeof value === 'string' && OPAQUE_TOKEN.test(value);
|
|
62
|
+
}
|
|
63
|
+
function normalizeDeferredHandoffToken(raw, acceptedPrefixes = DEFAULT_PREFIXES) {
|
|
64
|
+
if (typeof raw !== 'string') return null;
|
|
65
|
+
let candidate = raw.trim();
|
|
66
|
+
const lower = candidate.toLowerCase();
|
|
67
|
+
const prefix = acceptedPrefixes.find(value => {
|
|
68
|
+
const normalized = value.trim().toLowerCase();
|
|
69
|
+
return normalized.length > 1 && lower.startsWith(normalized);
|
|
70
|
+
});
|
|
71
|
+
if (prefix) candidate = candidate.slice(prefix.trim().length);
|
|
72
|
+
return isOpaqueDeferredLinkToken(candidate) ? candidate : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Extracts only explicit handoff query keys or /handoff|d/{token} path segments. */
|
|
76
|
+
function extractDeferredHandoffToken(url) {
|
|
77
|
+
if (typeof url !== 'string' || url.length === 0 || url.length > 4_096) return null;
|
|
78
|
+
let parsed;
|
|
79
|
+
try {
|
|
80
|
+
parsed = new URL(url);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (!parsed.protocol) return null;
|
|
85
|
+
const queryCandidates = [];
|
|
86
|
+
for (const [key, value] of parsed.searchParams.entries()) {
|
|
87
|
+
if (DEFAULT_QUERY_KEYS.has(key.toLowerCase())) queryCandidates.push(value);
|
|
88
|
+
}
|
|
89
|
+
if (queryCandidates.length > 1) return null;
|
|
90
|
+
if (queryCandidates.length === 1) return normalizeDeferredHandoffToken(queryCandidates[0]);
|
|
91
|
+
const segments = [parsed.hostname, ...parsed.pathname.split('/')].filter(Boolean);
|
|
92
|
+
for (let index = 0; index < segments.length - 1; index += 1) {
|
|
93
|
+
const marker = segments[index]?.toLowerCase();
|
|
94
|
+
if (marker !== 'handoff' && marker !== 'd') continue;
|
|
95
|
+
try {
|
|
96
|
+
return normalizeDeferredHandoffToken(decodeURIComponent(segments[index + 1] ?? ''));
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Runtime-validates the native result so raw bridge data can never leak into app state. */
|
|
105
|
+
function normalizeAndroidInstallReferrerResult(raw) {
|
|
106
|
+
if (!raw || typeof raw !== 'object') {
|
|
107
|
+
return {
|
|
108
|
+
status: 'SERVICE_UNAVAILABLE',
|
|
109
|
+
retryable: true,
|
|
110
|
+
attempts: 0
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const value = raw;
|
|
114
|
+
const status = typeof value.status === 'string' && INSTALL_REFERRER_STATUSES.has(value.status) ? value.status : 'DEVELOPER_ERROR';
|
|
115
|
+
const attempts = typeof value.attempts === 'number' && Number.isFinite(value.attempts) ? Math.max(0, Math.min(3, Math.trunc(value.attempts))) : 0;
|
|
116
|
+
const expectedRetryable = status === 'SERVICE_UNAVAILABLE' || status === 'SERVICE_DISCONNECTED';
|
|
117
|
+
const token = status === 'OK' ? normalizeDeferredHandoffToken(value.token) : null;
|
|
118
|
+
if (status === 'OK' && !token) return {
|
|
119
|
+
status: 'NO_TOKEN',
|
|
120
|
+
retryable: false,
|
|
121
|
+
attempts
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
status,
|
|
125
|
+
retryable: expectedRetryable,
|
|
126
|
+
attempts,
|
|
127
|
+
...(token ? {
|
|
128
|
+
token
|
|
129
|
+
} : {})
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function createPulseLinkClient(options) {
|
|
133
|
+
const resolverBaseUrl = normalizeBaseUrl(options.resolverBaseUrl);
|
|
134
|
+
if (!/^https:\/\//i.test(resolverBaseUrl)) {
|
|
135
|
+
throw new Error('Pulse Links: resolverBaseUrl must use HTTPS');
|
|
136
|
+
}
|
|
137
|
+
const allowedDeepLinkPrefixes = options.allowedDeepLinkPrefixes.map(prefix => prefix.trim().toLowerCase()).filter(prefix => prefix.length > 0 && prefix.length <= 256);
|
|
138
|
+
if (allowedDeepLinkPrefixes.length === 0) {
|
|
139
|
+
throw new Error('Pulse Links: at least one app-owned deep-link prefix is required');
|
|
140
|
+
}
|
|
141
|
+
const allowedActions = new Set((options.allowedActions ?? DEFAULT_ACTIONS).filter(Boolean));
|
|
142
|
+
const sensitiveActions = new Set((options.sensitiveActions ?? DEFAULT_SENSITIVE_ACTIONS).filter(Boolean));
|
|
143
|
+
const now = options.now ?? Date.now;
|
|
144
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
145
|
+
const appSlug = normalizeAppSlug(options.appSlug);
|
|
146
|
+
const explicitStorageKey = options.storageKey?.trim();
|
|
147
|
+
if (options.storage && !explicitStorageKey && !appSlug) {
|
|
148
|
+
throw new Error('Pulse Links: appSlug or an app-scoped storageKey is required when storage is used');
|
|
149
|
+
}
|
|
150
|
+
const storageKey = explicitStorageKey || (appSlug ? `pulse.${appSlug}.links.v1` : DEFAULT_STORAGE_KEY);
|
|
151
|
+
const requestTimeoutMs = boundedDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 500, 60_000);
|
|
152
|
+
const retryBaseMs = boundedDuration(options.retryBaseMs, DEFAULT_RETRY_BASE_MS, 100, 60 * 60 * 1_000);
|
|
153
|
+
const retryMaxMs = boundedDuration(options.retryMaxMs, DEFAULT_RETRY_MAX_MS, retryBaseMs, 24 * 60 * 60 * 1_000);
|
|
154
|
+
const tokenMaxAgeMs = boundedDuration(options.tokenMaxAgeMs, DEFAULT_TOKEN_MAX_AGE_MS, 60_000, 365 * 24 * 60 * 60 * 1_000);
|
|
155
|
+
const recentInstallMaxAgeMs = boundedDuration(options.recentInstallMaxAgeMs, DEFAULT_RECENT_INSTALL_MAX_AGE_MS, 60_000, 7 * 24 * 60 * 60 * 1_000);
|
|
156
|
+
const isPublicToken = options.isPublicToken ?? (token => PUBLIC_EXPOSURE.test(token));
|
|
157
|
+
let state = readState(options.storage, storageKey);
|
|
158
|
+
let disposed = false;
|
|
159
|
+
let processing = null;
|
|
160
|
+
let processRequested = false;
|
|
161
|
+
let retryTimer = null;
|
|
162
|
+
let accountRetryAt = 0;
|
|
163
|
+
let accountAttempts = 0;
|
|
164
|
+
let lastFirstOpenContext = null;
|
|
165
|
+
let unsubscribeAccount = null;
|
|
166
|
+
const listeners = new Set();
|
|
167
|
+
const reportError = error => {
|
|
168
|
+
try {
|
|
169
|
+
options.onError?.(error);
|
|
170
|
+
} catch {/* diagnostic hooks never break link handling */}
|
|
171
|
+
};
|
|
172
|
+
const snapshot = () => ({
|
|
173
|
+
status: state.status,
|
|
174
|
+
pending: state.pending ? {
|
|
175
|
+
...state.pending
|
|
176
|
+
} : null,
|
|
177
|
+
lastAppliedId: state.lastAppliedId,
|
|
178
|
+
appliedIds: [...state.appliedIds],
|
|
179
|
+
firstOpen: {
|
|
180
|
+
...state.firstOpen
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
const persistAndNotify = () => {
|
|
184
|
+
writeState(options.storage, storageKey, state);
|
|
185
|
+
const value = snapshot();
|
|
186
|
+
for (const listener of listeners) {
|
|
187
|
+
try {
|
|
188
|
+
listener(value);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
reportError(error);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
options.onStateChange?.(value);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
reportError(error);
|
|
197
|
+
}
|
|
198
|
+
scheduleWake();
|
|
199
|
+
};
|
|
200
|
+
const patchState = patch => {
|
|
201
|
+
state = {
|
|
202
|
+
...state,
|
|
203
|
+
...patch
|
|
204
|
+
};
|
|
205
|
+
persistAndNotify();
|
|
206
|
+
};
|
|
207
|
+
const patchFirstOpen = patch => {
|
|
208
|
+
state = {
|
|
209
|
+
...state,
|
|
210
|
+
firstOpen: {
|
|
211
|
+
...state.firstOpen,
|
|
212
|
+
...patch
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
persistAndNotify();
|
|
216
|
+
};
|
|
217
|
+
const isAccountReady = () => {
|
|
218
|
+
try {
|
|
219
|
+
return options.isAccountReady?.() ?? false;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
reportError(error);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
const schedulePendingRetry = () => {
|
|
226
|
+
const pending = state.pending;
|
|
227
|
+
if (!pending) return;
|
|
228
|
+
const attempts = pending.attempts + 1;
|
|
229
|
+
const delay = Math.min(retryBaseMs * 2 ** Math.min(attempts - 1, 10), retryMaxMs);
|
|
230
|
+
state = {
|
|
231
|
+
...state,
|
|
232
|
+
status: 'retryable_error',
|
|
233
|
+
pending: {
|
|
234
|
+
...pending,
|
|
235
|
+
attempts,
|
|
236
|
+
nextRetryAt: now() + delay
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
persistAndNotify();
|
|
240
|
+
};
|
|
241
|
+
const scheduleFirstOpenRetry = () => {
|
|
242
|
+
const attempts = state.firstOpen.attempts + 1;
|
|
243
|
+
const delay = Math.min(retryBaseMs * 2 ** Math.min(attempts - 1, 10), retryMaxMs);
|
|
244
|
+
state = {
|
|
245
|
+
...state,
|
|
246
|
+
status: 'retryable_error',
|
|
247
|
+
firstOpen: {
|
|
248
|
+
...state.firstOpen,
|
|
249
|
+
attempts,
|
|
250
|
+
nextRetryAt: now() + delay
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
persistAndNotify();
|
|
254
|
+
};
|
|
255
|
+
const scheduleAccountRetry = () => {
|
|
256
|
+
accountAttempts += 1;
|
|
257
|
+
const delay = Math.min(retryBaseMs * 2 ** Math.min(accountAttempts - 1, 10), retryMaxMs);
|
|
258
|
+
accountRetryAt = now() + delay;
|
|
259
|
+
patchState({
|
|
260
|
+
status: 'retryable_error'
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
const clearPendingAsTerminal = () => {
|
|
264
|
+
patchState({
|
|
265
|
+
pending: null,
|
|
266
|
+
status: 'terminal_error'
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
const emitOutcome = (link, name) => {
|
|
270
|
+
const transitionKey = stableOutcomeKey(link.id, name);
|
|
271
|
+
if (!state.notifiedOutcomes.includes(transitionKey)) {
|
|
272
|
+
// Persist before handing off to the general Pulse outbox. A process death after the callback
|
|
273
|
+
// queues an event must not queue the same product transition again on restart.
|
|
274
|
+
state = {
|
|
275
|
+
...state,
|
|
276
|
+
notifiedOutcomes: [...state.notifiedOutcomes, transitionKey].slice(-MAX_NOTIFIED_OUTCOMES)
|
|
277
|
+
};
|
|
278
|
+
writeState(options.storage, storageKey, state);
|
|
279
|
+
const event = {
|
|
280
|
+
name,
|
|
281
|
+
matchBasis: link.matchBasis,
|
|
282
|
+
confidence: link.confidence,
|
|
283
|
+
...(link.campaignId !== undefined ? {
|
|
284
|
+
campaignId: link.campaignId
|
|
285
|
+
} : {}),
|
|
286
|
+
...(link.experimentId !== undefined ? {
|
|
287
|
+
experimentId: link.experimentId
|
|
288
|
+
} : {}),
|
|
289
|
+
...(link.variantId !== undefined ? {
|
|
290
|
+
variantId: link.variantId
|
|
291
|
+
} : {}),
|
|
292
|
+
occurredAt: new Date(now()).toISOString()
|
|
293
|
+
};
|
|
294
|
+
try {
|
|
295
|
+
const result = options.onOutcome?.(event);
|
|
296
|
+
if (result && typeof result.catch === 'function') {
|
|
297
|
+
void result.catch(reportError);
|
|
298
|
+
}
|
|
299
|
+
} catch (error) {
|
|
300
|
+
reportError(error);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if ((options.reportResolverOutcomes ?? true) && isPublicToken(link.id)) {
|
|
304
|
+
void postResolverOutcome(fetcher, resolverBaseUrl, link, name, requestTimeoutMs).catch(reportError);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
const applyResolved = async (link, sourceToken) => {
|
|
308
|
+
if (disposed) return false;
|
|
309
|
+
if (state.appliedIds.includes(link.id)) {
|
|
310
|
+
if (sourceToken && state.pending?.token === sourceToken) {
|
|
311
|
+
state = {
|
|
312
|
+
...state,
|
|
313
|
+
status: 'applied',
|
|
314
|
+
pending: null,
|
|
315
|
+
appliedIds: [...state.appliedIds.filter(id => id !== sourceToken), sourceToken].slice(-MAX_APPLIED_IDS)
|
|
316
|
+
};
|
|
317
|
+
persistAndNotify();
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
if (sourceToken && state.pending?.token !== sourceToken) return false;
|
|
322
|
+
if (link.matchBasis === 'unmatched') return false;
|
|
323
|
+
if (link.action && sensitiveActions.has(link.action)) {
|
|
324
|
+
if (!link.matchGuaranteed) {
|
|
325
|
+
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
if (!isAccountReady()) {
|
|
329
|
+
patchState({
|
|
330
|
+
status: 'waiting_for_account'
|
|
331
|
+
});
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Validate the whole product surface before measuring a successful resolution. Otherwise a
|
|
337
|
+
// malicious/out-of-contract URL would inflate the resolver funnel despite never being usable.
|
|
338
|
+
if (link.deepLink && !isAllowedDeepLink(link.deepLink, allowedDeepLinkPrefixes)) {
|
|
339
|
+
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
if (!link.deepLink && (!link.action || !options.onAction)) {
|
|
343
|
+
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
emitOutcome(link, 'app_open_confirmed');
|
|
347
|
+
emitOutcome(link, 'deferred_link_resolved');
|
|
348
|
+
let applied;
|
|
349
|
+
try {
|
|
350
|
+
if (link.deepLink) {
|
|
351
|
+
applied = await options.onDeepLink(link.deepLink, link);
|
|
352
|
+
} else if (link.action && options.onAction) {
|
|
353
|
+
applied = await options.onAction(link);
|
|
354
|
+
} else {
|
|
355
|
+
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
} catch (error) {
|
|
359
|
+
reportError(error);
|
|
360
|
+
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
if (applied === false) {
|
|
364
|
+
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
const newlyApplied = sourceToken && sourceToken !== link.id ? [link.id, sourceToken] : [link.id];
|
|
368
|
+
const newlyAppliedSet = new Set(newlyApplied);
|
|
369
|
+
const appliedIds = [...state.appliedIds.filter(id => !newlyAppliedSet.has(id)), ...newlyApplied].slice(-MAX_APPLIED_IDS);
|
|
370
|
+
const stillPending = sourceToken !== null && state.pending?.token !== sourceToken;
|
|
371
|
+
state = {
|
|
372
|
+
...state,
|
|
373
|
+
status: stillPending ? 'pending' : 'applied',
|
|
374
|
+
pending: sourceToken && state.pending?.token === sourceToken ? null : state.pending,
|
|
375
|
+
lastAppliedId: link.id,
|
|
376
|
+
appliedIds
|
|
377
|
+
};
|
|
378
|
+
persistAndNotify();
|
|
379
|
+
if (link.matchBasis === 'account_bound') {
|
|
380
|
+
accountAttempts = 0;
|
|
381
|
+
accountRetryAt = 0;
|
|
382
|
+
}
|
|
383
|
+
emitOutcome(link, 'action_applied');
|
|
384
|
+
if (stillPending) void process();
|
|
385
|
+
return true;
|
|
386
|
+
};
|
|
387
|
+
const resolvePublic = async (token, basis) => {
|
|
388
|
+
if (!isPublicToken(token)) return {
|
|
389
|
+
kind: 'not_found'
|
|
390
|
+
};
|
|
391
|
+
try {
|
|
392
|
+
const response = await withTimeout(fetcher, `${resolverBaseUrl}${encodeURIComponent(token)}`, {
|
|
393
|
+
method: 'GET',
|
|
394
|
+
headers: {
|
|
395
|
+
Accept: 'application/json'
|
|
396
|
+
}
|
|
397
|
+
}, requestTimeoutMs);
|
|
398
|
+
if (response.status === 404 || response.status === 410) return {
|
|
399
|
+
kind: 'not_found'
|
|
400
|
+
};
|
|
401
|
+
if (!response.ok) {
|
|
402
|
+
return {
|
|
403
|
+
kind: response.status === 429 || response.status >= 500 ? 'retryable' : 'not_found'
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
const link = normalizeResolved(await response.json(), token, basis, allowedActions, now());
|
|
407
|
+
return link ? {
|
|
408
|
+
kind: 'resolved',
|
|
409
|
+
link
|
|
410
|
+
} : {
|
|
411
|
+
kind: 'not_found'
|
|
412
|
+
};
|
|
413
|
+
} catch {
|
|
414
|
+
return {
|
|
415
|
+
kind: 'retryable'
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
const processOnce = async () => {
|
|
420
|
+
if (disposed) return;
|
|
421
|
+
const pending = state.pending;
|
|
422
|
+
if (pending) {
|
|
423
|
+
if (pending.nextRetryAt > now()) return;
|
|
424
|
+
if (now() - pending.receivedAt > tokenMaxAgeMs) {
|
|
425
|
+
clearPendingAsTerminal();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const token = pending.token;
|
|
429
|
+
patchState({
|
|
430
|
+
status: 'resolving'
|
|
431
|
+
});
|
|
432
|
+
const publicResult = await resolvePublic(token, pending.matchBasis);
|
|
433
|
+
if (disposed || state.pending?.token !== token) return;
|
|
434
|
+
if (publicResult.kind === 'resolved') {
|
|
435
|
+
await applyResolved(publicResult.link, token);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (publicResult.kind === 'retryable') {
|
|
439
|
+
schedulePendingRetry();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (!options.accountBridge || !isAccountReady()) {
|
|
443
|
+
patchState({
|
|
444
|
+
status: 'waiting_for_account'
|
|
445
|
+
});
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
const raw = await options.accountBridge.claim(token);
|
|
450
|
+
if (disposed || state.pending?.token !== token) return;
|
|
451
|
+
const normalizedClaim = normalizeResolved(raw, token, 'account_bound', allowedActions, now());
|
|
452
|
+
const claimed = normalizedClaim ? {
|
|
453
|
+
...normalizedClaim,
|
|
454
|
+
matchBasis: 'account_bound',
|
|
455
|
+
matchGuaranteed: true,
|
|
456
|
+
confidence: 1
|
|
457
|
+
} : null;
|
|
458
|
+
if (claimed) await applyResolved(claimed, token);else clearPendingAsTerminal();
|
|
459
|
+
} catch (error) {
|
|
460
|
+
reportError(error);
|
|
461
|
+
if (state.pending?.token === token) schedulePendingRetry();
|
|
462
|
+
}
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (!options.accountBridge || !isAccountReady() || accountRetryAt > now()) return;
|
|
466
|
+
try {
|
|
467
|
+
const raw = await options.accountBridge.pending();
|
|
468
|
+
if (!raw) {
|
|
469
|
+
accountAttempts = 0;
|
|
470
|
+
accountRetryAt = 0;
|
|
471
|
+
if (state.status === 'waiting_for_account' || state.status === 'retryable_error') {
|
|
472
|
+
patchState({
|
|
473
|
+
status: 'idle'
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const fallbackId = raw && typeof raw === 'object' && typeof raw.id === 'string' ? raw.id : 'account-bound-pending';
|
|
479
|
+
const normalizedPending = normalizeResolved(raw, fallbackId, 'account_bound', allowedActions, now());
|
|
480
|
+
const pendingLink = normalizedPending ? {
|
|
481
|
+
...normalizedPending,
|
|
482
|
+
matchBasis: 'account_bound',
|
|
483
|
+
matchGuaranteed: true,
|
|
484
|
+
confidence: 1
|
|
485
|
+
} : null;
|
|
486
|
+
if (pendingLink && !state.appliedIds.includes(pendingLink.id)) {
|
|
487
|
+
await applyResolved(pendingLink, null);
|
|
488
|
+
} else if (pendingLink && state.status !== 'applied') {
|
|
489
|
+
patchState({
|
|
490
|
+
status: 'applied'
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
} catch (error) {
|
|
494
|
+
reportError(error);
|
|
495
|
+
// No token was consumed; the authenticated server outbox remains authoritative.
|
|
496
|
+
scheduleAccountRetry();
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
const process = async () => {
|
|
500
|
+
if (disposed) return;
|
|
501
|
+
processRequested = true;
|
|
502
|
+
if (processing) return processing;
|
|
503
|
+
const run = (async () => {
|
|
504
|
+
do {
|
|
505
|
+
processRequested = false;
|
|
506
|
+
await processOnce();
|
|
507
|
+
} while (processRequested && !disposed);
|
|
508
|
+
})();
|
|
509
|
+
let ownedPromise;
|
|
510
|
+
ownedPromise = run.catch(reportError).then(async () => {
|
|
511
|
+
if (processing === ownedPromise) processing = null;
|
|
512
|
+
// A capture can arrive after the drain's last condition check but before its promise
|
|
513
|
+
// continuation runs. Chain that late request instead of losing the wakeup.
|
|
514
|
+
if (processRequested && !disposed) await process();
|
|
515
|
+
});
|
|
516
|
+
processing = ownedPromise;
|
|
517
|
+
return ownedPromise;
|
|
518
|
+
};
|
|
519
|
+
const capture = (rawToken, basis) => {
|
|
520
|
+
if (disposed || !CAPTURE_BASES.has(basis)) return false;
|
|
521
|
+
const token = normalizeDeferredHandoffToken(rawToken);
|
|
522
|
+
if (!token) return false;
|
|
523
|
+
if (state.appliedIds.includes(token)) {
|
|
524
|
+
if (!state.firstOpen.completed) patchFirstOpen({
|
|
525
|
+
completed: true,
|
|
526
|
+
nextRetryAt: 0
|
|
527
|
+
});
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
if (state.pending?.token === token) {
|
|
531
|
+
if (state.pending.matchBasis === 'direct_token' && basis !== 'direct_token') {
|
|
532
|
+
state = {
|
|
533
|
+
...state,
|
|
534
|
+
pending: {
|
|
535
|
+
...state.pending,
|
|
536
|
+
matchBasis: basis
|
|
537
|
+
},
|
|
538
|
+
firstOpen: {
|
|
539
|
+
...state.firstOpen,
|
|
540
|
+
completed: true,
|
|
541
|
+
nextRetryAt: 0
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
persistAndNotify();
|
|
545
|
+
} else if (!state.firstOpen.completed) {
|
|
546
|
+
patchFirstOpen({
|
|
547
|
+
completed: true,
|
|
548
|
+
nextRetryAt: 0
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
void process();
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
state = {
|
|
555
|
+
...state,
|
|
556
|
+
status: 'pending',
|
|
557
|
+
pending: {
|
|
558
|
+
token,
|
|
559
|
+
receivedAt: now(),
|
|
560
|
+
matchBasis: basis,
|
|
561
|
+
attempts: 0,
|
|
562
|
+
nextRetryAt: 0
|
|
563
|
+
},
|
|
564
|
+
firstOpen: {
|
|
565
|
+
...state.firstOpen,
|
|
566
|
+
completed: true,
|
|
567
|
+
nextRetryAt: 0
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
persistAndNotify();
|
|
571
|
+
void process();
|
|
572
|
+
return true;
|
|
573
|
+
};
|
|
574
|
+
const matchFirstOpen = async context => {
|
|
575
|
+
if (disposed) return 'ineligible';
|
|
576
|
+
lastFirstOpenContext = context;
|
|
577
|
+
if (state.pending) return 'deterministic_pending';
|
|
578
|
+
if (state.firstOpen.completed) return 'already_completed';
|
|
579
|
+
if (state.firstOpen.nextRetryAt > now()) return 'backoff';
|
|
580
|
+
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
581
|
+
const installAttemptId = state.firstOpen.installAttemptId ?? makeInstallAttemptId(options.randomUUID);
|
|
582
|
+
state = {
|
|
583
|
+
...state,
|
|
584
|
+
status: 'resolving',
|
|
585
|
+
firstOpen: {
|
|
586
|
+
...state.firstOpen,
|
|
587
|
+
installAttemptId,
|
|
588
|
+
attemptedAt: now()
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
persistAndNotify();
|
|
592
|
+
const body = {
|
|
593
|
+
appBundleId: context.appBundleId.trim(),
|
|
594
|
+
platform: 'ios',
|
|
595
|
+
locale: normalizeLocale(context.locale),
|
|
596
|
+
firstOpen: true,
|
|
597
|
+
installAttemptId
|
|
598
|
+
};
|
|
599
|
+
try {
|
|
600
|
+
const response = await withTimeout(fetcher, matchUrlOf(resolverBaseUrl), {
|
|
601
|
+
method: 'POST',
|
|
602
|
+
headers: {
|
|
603
|
+
Accept: 'application/json',
|
|
604
|
+
'Content-Type': 'application/json'
|
|
605
|
+
},
|
|
606
|
+
body: JSON.stringify(body)
|
|
607
|
+
}, requestTimeoutMs);
|
|
608
|
+
if (state.pending) return 'deterministic_pending';
|
|
609
|
+
if (state.firstOpen.completed) return 'deterministic_pending';
|
|
610
|
+
if (response.status === 204) {
|
|
611
|
+
patchFirstOpen({
|
|
612
|
+
completed: true,
|
|
613
|
+
attempts: 0,
|
|
614
|
+
nextRetryAt: 0
|
|
615
|
+
});
|
|
616
|
+
patchState({
|
|
617
|
+
status: 'idle'
|
|
618
|
+
});
|
|
619
|
+
return 'no_match';
|
|
620
|
+
}
|
|
621
|
+
if (response.status === 400 || response.status === 404 || response.status === 410) {
|
|
622
|
+
patchFirstOpen({
|
|
623
|
+
completed: true,
|
|
624
|
+
nextRetryAt: 0
|
|
625
|
+
});
|
|
626
|
+
patchState({
|
|
627
|
+
status: 'terminal_error'
|
|
628
|
+
});
|
|
629
|
+
return 'terminal_error';
|
|
630
|
+
}
|
|
631
|
+
if (!response.ok) {
|
|
632
|
+
if (response.status === 429 || response.status >= 500) {
|
|
633
|
+
scheduleFirstOpenRetry();
|
|
634
|
+
return 'retry_scheduled';
|
|
635
|
+
}
|
|
636
|
+
patchFirstOpen({
|
|
637
|
+
completed: true,
|
|
638
|
+
nextRetryAt: 0
|
|
639
|
+
});
|
|
640
|
+
patchState({
|
|
641
|
+
status: 'terminal_error'
|
|
642
|
+
});
|
|
643
|
+
return 'terminal_error';
|
|
644
|
+
}
|
|
645
|
+
const link = normalizeResolved(await response.json(), installAttemptId.replace(/-/g, ''), 'unique_probabilistic', allowedActions, now());
|
|
646
|
+
// The probabilistic endpoint can personalize harmless UX only. It can never upgrade its own
|
|
647
|
+
// evidence to guaranteed or return a deterministic/account basis.
|
|
648
|
+
if (!link || link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed || link.action && sensitiveActions.has(link.action)) {
|
|
649
|
+
patchFirstOpen({
|
|
650
|
+
completed: true,
|
|
651
|
+
nextRetryAt: 0
|
|
652
|
+
});
|
|
653
|
+
patchState({
|
|
654
|
+
status: 'terminal_error'
|
|
655
|
+
});
|
|
656
|
+
return 'terminal_error';
|
|
657
|
+
}
|
|
658
|
+
if (await applyResolved(link, null)) {
|
|
659
|
+
patchFirstOpen({
|
|
660
|
+
completed: true,
|
|
661
|
+
attempts: 0,
|
|
662
|
+
nextRetryAt: 0
|
|
663
|
+
});
|
|
664
|
+
return 'matched';
|
|
665
|
+
}
|
|
666
|
+
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
667
|
+
return 'retry_scheduled';
|
|
668
|
+
} catch (error) {
|
|
669
|
+
reportError(error);
|
|
670
|
+
scheduleFirstOpenRetry();
|
|
671
|
+
return 'retry_scheduled';
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
const captureAndroidInstallReferrer = async bridge => {
|
|
675
|
+
let raw;
|
|
676
|
+
try {
|
|
677
|
+
raw = await bridge.getDeferredHandoff();
|
|
678
|
+
} catch (error) {
|
|
679
|
+
reportError(error);
|
|
680
|
+
return {
|
|
681
|
+
status: 'SERVICE_UNAVAILABLE',
|
|
682
|
+
retryable: true,
|
|
683
|
+
attempts: 0
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
const result = normalizeAndroidInstallReferrerResult(raw);
|
|
687
|
+
if (result.status === 'OK' && result.token) {
|
|
688
|
+
capture(result.token, 'android_install_referrer');
|
|
689
|
+
}
|
|
690
|
+
return result;
|
|
691
|
+
};
|
|
692
|
+
function scheduleWake() {
|
|
693
|
+
if (retryTimer) {
|
|
694
|
+
clearTimeout(retryTimer);
|
|
695
|
+
retryTimer = null;
|
|
696
|
+
}
|
|
697
|
+
if (disposed || options.autoRetry === false) return;
|
|
698
|
+
const candidates = [state.pending?.nextRetryAt ?? 0, lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0, accountRetryAt].filter(value => value > now());
|
|
699
|
+
if (candidates.length === 0) return;
|
|
700
|
+
const next = Math.min(...candidates);
|
|
701
|
+
retryTimer = setTimeout(() => {
|
|
702
|
+
retryTimer = null;
|
|
703
|
+
void process();
|
|
704
|
+
if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
|
|
705
|
+
void matchFirstOpen(lastFirstOpenContext);
|
|
706
|
+
}
|
|
707
|
+
}, Math.max(0, next - now()));
|
|
708
|
+
const maybeTimer = retryTimer;
|
|
709
|
+
maybeTimer.unref?.();
|
|
710
|
+
}
|
|
711
|
+
if (options.subscribeAccountState) {
|
|
712
|
+
try {
|
|
713
|
+
unsubscribeAccount = options.subscribeAccountState(() => {
|
|
714
|
+
void process();
|
|
715
|
+
});
|
|
716
|
+
} catch (error) {
|
|
717
|
+
reportError(error);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
scheduleWake();
|
|
721
|
+
void process();
|
|
722
|
+
return {
|
|
723
|
+
capture,
|
|
724
|
+
captureUrl: url => {
|
|
725
|
+
const token = extractDeferredHandoffToken(url);
|
|
726
|
+
return token ? capture(token, 'direct_token') : false;
|
|
727
|
+
},
|
|
728
|
+
process,
|
|
729
|
+
matchFirstOpen,
|
|
730
|
+
captureAndroidInstallReferrer,
|
|
731
|
+
getState: snapshot,
|
|
732
|
+
subscribe: listener => {
|
|
733
|
+
listeners.add(listener);
|
|
734
|
+
return () => listeners.delete(listener);
|
|
735
|
+
},
|
|
736
|
+
dispose: () => {
|
|
737
|
+
if (disposed) return;
|
|
738
|
+
disposed = true;
|
|
739
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
740
|
+
retryTimer = null;
|
|
741
|
+
unsubscribeAccount?.();
|
|
742
|
+
unsubscribeAccount = null;
|
|
743
|
+
listeners.clear();
|
|
744
|
+
},
|
|
745
|
+
reset: () => {
|
|
746
|
+
accountRetryAt = 0;
|
|
747
|
+
accountAttempts = 0;
|
|
748
|
+
state = emptyState();
|
|
749
|
+
persistAndNotify();
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** @deprecated Prefer the product-facing `createPulseLinkClient` name. */
|
|
755
|
+
const createDeferredLinkClient = exports.createDeferredLinkClient = createPulseLinkClient;
|
|
756
|
+
function normalizeBaseUrl(base) {
|
|
757
|
+
return `${base.trim().replace(/\/+$/, '')}/`;
|
|
758
|
+
}
|
|
759
|
+
function normalizeAppSlug(value) {
|
|
760
|
+
if (typeof value !== 'string') return null;
|
|
761
|
+
const slug = value.trim().toLowerCase();
|
|
762
|
+
return /^[a-z0-9][a-z0-9_-]{0,79}$/.test(slug) ? slug : null;
|
|
763
|
+
}
|
|
764
|
+
function matchUrlOf(resolverBaseUrl) {
|
|
765
|
+
return resolverBaseUrl.replace(/\/t\/d\/$/i, '/t/match');
|
|
766
|
+
}
|
|
767
|
+
function boundedDuration(value, fallback, min, max) {
|
|
768
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
|
769
|
+
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
770
|
+
}
|
|
771
|
+
function isAllowedDeepLink(deepLink, prefixes) {
|
|
772
|
+
const normalized = deepLink.trim().toLowerCase();
|
|
773
|
+
return normalized.length > 0 && normalized.length <= 2_048 && prefixes.some(prefix => normalized.startsWith(prefix));
|
|
774
|
+
}
|
|
775
|
+
function clampConfidence(value, fallback) {
|
|
776
|
+
const number = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
777
|
+
return Math.max(0, Math.min(1, number));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Non-cryptographic local dedupe key. It is never authorization evidence or sent to analytics. */
|
|
781
|
+
function stableOutcomeKey(linkId, name) {
|
|
782
|
+
let first = 0x811c9dc5;
|
|
783
|
+
let second = 0x9e3779b9;
|
|
784
|
+
for (let index = 0; index < linkId.length; index += 1) {
|
|
785
|
+
const code = linkId.charCodeAt(index);
|
|
786
|
+
first = Math.imul(first ^ code, 0x01000193) >>> 0;
|
|
787
|
+
second = Math.imul(second ^ code + index, 0x85ebca6b) >>> 0;
|
|
788
|
+
}
|
|
789
|
+
return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}|${name}`;
|
|
790
|
+
}
|
|
791
|
+
function boundedMetadata(value) {
|
|
792
|
+
if (value === null) return null;
|
|
793
|
+
if (typeof value !== 'string' && typeof value !== 'number') return undefined;
|
|
794
|
+
const result = String(value).trim();
|
|
795
|
+
return result.length > 0 && result.length <= 256 ? result : undefined;
|
|
796
|
+
}
|
|
797
|
+
function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now) {
|
|
798
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
799
|
+
const value = raw;
|
|
800
|
+
const action = typeof value.action === 'string' && allowedActions.has(value.action) ? value.action : undefined;
|
|
801
|
+
const deepLink = typeof value.deepLink === 'string' && value.deepLink.length <= 2_048 ? value.deepLink.trim() : undefined;
|
|
802
|
+
if (!action && !deepLink) return null;
|
|
803
|
+
const rawBasis = typeof value.matchBasis === 'string' && MATCH_BASES.has(value.matchBasis) ? value.matchBasis : fallbackBasis;
|
|
804
|
+
const matchBasis = rawBasis === 'direct_token' && (fallbackBasis === 'android_install_referrer' || fallbackBasis === 'ios_user_paste') ? fallbackBasis : rawBasis;
|
|
805
|
+
const guaranteedDefault = matchBasis !== 'unique_probabilistic' && matchBasis !== 'unmatched';
|
|
806
|
+
const matchGuaranteed = matchBasis === 'unique_probabilistic' || matchBasis === 'unmatched' ? false : typeof value.matchGuaranteed === 'boolean' ? value.matchGuaranteed : guaranteedDefault;
|
|
807
|
+
const confidence = clampConfidence(value.confidence, matchGuaranteed ? 1 : 0);
|
|
808
|
+
const rawId = typeof value.id === 'string' ? value.id : fallbackId;
|
|
809
|
+
const id = normalizeDeferredHandoffToken(rawId) ?? normalizeDeferredHandoffToken(fallbackId);
|
|
810
|
+
if (!id) return null;
|
|
811
|
+
const expiresAt = typeof value.expiresAt === 'string' ? value.expiresAt : undefined;
|
|
812
|
+
if (expiresAt) {
|
|
813
|
+
const expiry = new Date(expiresAt).getTime();
|
|
814
|
+
if (!Number.isFinite(expiry) || expiry <= now) return null;
|
|
815
|
+
}
|
|
816
|
+
const source = boundedMetadata(value.source);
|
|
817
|
+
const campaignId = boundedMetadata(value.campaignId);
|
|
818
|
+
const experimentId = boundedMetadata(value.experimentId);
|
|
819
|
+
const variantId = boundedMetadata(value.variantId);
|
|
820
|
+
return {
|
|
821
|
+
id,
|
|
822
|
+
...(action ? {
|
|
823
|
+
action
|
|
824
|
+
} : {}),
|
|
825
|
+
...(deepLink ? {
|
|
826
|
+
deepLink
|
|
827
|
+
} : {}),
|
|
828
|
+
...(expiresAt ? {
|
|
829
|
+
expiresAt
|
|
830
|
+
} : {}),
|
|
831
|
+
...(source !== undefined ? {
|
|
832
|
+
source
|
|
833
|
+
} : {}),
|
|
834
|
+
...(campaignId !== undefined ? {
|
|
835
|
+
campaignId
|
|
836
|
+
} : {}),
|
|
837
|
+
...(experimentId !== undefined ? {
|
|
838
|
+
experimentId
|
|
839
|
+
} : {}),
|
|
840
|
+
...(variantId !== undefined ? {
|
|
841
|
+
variantId
|
|
842
|
+
} : {}),
|
|
843
|
+
matchBasis,
|
|
844
|
+
matchGuaranteed,
|
|
845
|
+
confidence
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
function isEligibleFirstOpen(context, now, maxAgeMs) {
|
|
849
|
+
const appBundleId = context.appBundleId.trim();
|
|
850
|
+
const locale = normalizeLocale(context.locale);
|
|
851
|
+
return context.platform === 'ios' && APP_IDENTITY.test(appBundleId) && appBundleId.length <= 200 && LOCALE.test(locale) && locale.length <= 35 && Number.isFinite(context.installedAt) && context.installedAt > 0 && context.installedAt <= now && now - context.installedAt <= maxAgeMs;
|
|
852
|
+
}
|
|
853
|
+
function normalizeLocale(locale) {
|
|
854
|
+
return locale.trim().replace(/_/g, '-');
|
|
855
|
+
}
|
|
856
|
+
function makeInstallAttemptId(custom) {
|
|
857
|
+
try {
|
|
858
|
+
const candidate = custom?.() ?? globalThis.crypto?.randomUUID?.();
|
|
859
|
+
if (candidate && INSTALL_ATTEMPT_ID.test(candidate)) return candidate.toLowerCase();
|
|
860
|
+
} catch {/* use the non-identity fallback below */}
|
|
861
|
+
const hex = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0');
|
|
862
|
+
return `${hex()}${hex()}-${hex()}-4${hex().slice(1)}-${(8 + Math.floor(Math.random() * 4)).toString(16)}${hex().slice(1)}-${hex()}${hex()}${hex()}`;
|
|
863
|
+
}
|
|
864
|
+
async function withTimeout(fetcher, input, init, timeoutMs) {
|
|
865
|
+
const controller = new AbortController();
|
|
866
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
867
|
+
try {
|
|
868
|
+
return await fetcher(input, {
|
|
869
|
+
...init,
|
|
870
|
+
signal: controller.signal
|
|
871
|
+
});
|
|
872
|
+
} finally {
|
|
873
|
+
clearTimeout(timer);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
async function postResolverOutcome(fetcher, resolverBaseUrl, link, name, timeoutMs) {
|
|
877
|
+
await withTimeout(fetcher, `${resolverBaseUrl}${encodeURIComponent(link.id)}/event/${name}`, {
|
|
878
|
+
method: 'POST',
|
|
879
|
+
headers: {
|
|
880
|
+
'Content-Type': 'application/json'
|
|
881
|
+
},
|
|
882
|
+
body: JSON.stringify({
|
|
883
|
+
matchBasis: link.matchBasis,
|
|
884
|
+
confidence: link.confidence
|
|
885
|
+
})
|
|
886
|
+
}, timeoutMs);
|
|
887
|
+
}
|
|
888
|
+
function readState(storage, key) {
|
|
889
|
+
if (!storage) return emptyState();
|
|
890
|
+
try {
|
|
891
|
+
const raw = storage.getString(key);
|
|
892
|
+
if (!raw || raw.length > MAX_PERSISTED_BYTES) return emptyState();
|
|
893
|
+
const parsed = JSON.parse(raw);
|
|
894
|
+
if (parsed.version !== 1) return emptyState();
|
|
895
|
+
const fallback = emptyState();
|
|
896
|
+
const status = typeof parsed.status === 'string' && STATUSES.has(parsed.status) ? parsed.status : fallback.status;
|
|
897
|
+
const pendingValue = parsed.pending && typeof parsed.pending === 'object' ? parsed.pending : null;
|
|
898
|
+
const pendingToken = normalizeDeferredHandoffToken(pendingValue?.token);
|
|
899
|
+
const pendingBasis = typeof pendingValue?.matchBasis === 'string' && CAPTURE_BASES.has(pendingValue.matchBasis) ? pendingValue.matchBasis : null;
|
|
900
|
+
const pending = pendingToken && pendingBasis ? {
|
|
901
|
+
token: pendingToken,
|
|
902
|
+
receivedAt: safeTimestamp(pendingValue?.receivedAt),
|
|
903
|
+
matchBasis: pendingBasis,
|
|
904
|
+
attempts: safeInteger(pendingValue?.attempts, 100_000),
|
|
905
|
+
nextRetryAt: safeTimestamp(pendingValue?.nextRetryAt)
|
|
906
|
+
} : null;
|
|
907
|
+
const appliedIds = Array.isArray(parsed.appliedIds) ? parsed.appliedIds.filter(isOpaqueDeferredLinkToken).slice(-MAX_APPLIED_IDS) : [];
|
|
908
|
+
const lastApplied = normalizeDeferredHandoffToken(parsed.lastAppliedId);
|
|
909
|
+
if (lastApplied && !appliedIds.includes(lastApplied)) appliedIds.push(lastApplied);
|
|
910
|
+
const firstValue = parsed.firstOpen && typeof parsed.firstOpen === 'object' ? parsed.firstOpen : {};
|
|
911
|
+
const attemptId = typeof firstValue.installAttemptId === 'string' && INSTALL_ATTEMPT_ID.test(firstValue.installAttemptId) ? firstValue.installAttemptId.toLowerCase() : null;
|
|
912
|
+
const notifiedOutcomes = Array.isArray(parsed.notifiedOutcomes) ? parsed.notifiedOutcomes.filter(value => typeof value === 'string' && /^[a-f0-9]{16}\|(app_open_confirmed|deferred_link_resolved|action_applied)$/.test(value)).slice(-MAX_NOTIFIED_OUTCOMES) : [];
|
|
913
|
+
return {
|
|
914
|
+
version: 1,
|
|
915
|
+
status: status === 'resolving' ? pending ? 'pending' : 'idle' : status,
|
|
916
|
+
pending,
|
|
917
|
+
lastAppliedId: lastApplied,
|
|
918
|
+
appliedIds: [...new Set(appliedIds)].slice(-MAX_APPLIED_IDS),
|
|
919
|
+
notifiedOutcomes: [...new Set(notifiedOutcomes)].slice(-MAX_NOTIFIED_OUTCOMES),
|
|
920
|
+
firstOpen: {
|
|
921
|
+
installAttemptId: attemptId,
|
|
922
|
+
completed: firstValue.completed === true,
|
|
923
|
+
attemptedAt: safeTimestamp(firstValue.attemptedAt),
|
|
924
|
+
attempts: safeInteger(firstValue.attempts, 100_000),
|
|
925
|
+
nextRetryAt: safeTimestamp(firstValue.nextRetryAt)
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
} catch {
|
|
929
|
+
return emptyState();
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
function writeState(storage, key, state) {
|
|
933
|
+
if (!storage) return;
|
|
934
|
+
try {
|
|
935
|
+
void storage.set(key, JSON.stringify(state));
|
|
936
|
+
} catch {/* in-memory operation continues */}
|
|
937
|
+
}
|
|
938
|
+
function safeTimestamp(value) {
|
|
939
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|
|
940
|
+
}
|
|
941
|
+
function safeInteger(value, max) {
|
|
942
|
+
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(max, Math.trunc(value))) : 0;
|
|
943
|
+
}
|
|
944
|
+
//# sourceMappingURL=links.js.map
|