pulse-updates 1.2.5 → 1.3.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/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.m +29 -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
package/src/links.ts
ADDED
|
@@ -0,0 +1,1232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse Links - a headless, app-neutral deferred-link client.
|
|
3
|
+
*
|
|
4
|
+
* Encore owns the phase-one HTTP resolver and attribution ledger. Pulse owns the reusable
|
|
5
|
+
* client boundary: capture, durable pending state, retry/idempotence, safe application and the
|
|
6
|
+
* privacy-preserving first-open request. Billing/account SDKs integrate only through the narrow
|
|
7
|
+
* optional account bridge below.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ConfigStorage } from './config';
|
|
11
|
+
|
|
12
|
+
export type DeferredLinkAction =
|
|
13
|
+
| 'open_home'
|
|
14
|
+
| 'open_premium'
|
|
15
|
+
| 'manage_subscription'
|
|
16
|
+
| 'refresh_entitlement';
|
|
17
|
+
|
|
18
|
+
export type DeferredLinkMatchBasis =
|
|
19
|
+
| 'direct_token'
|
|
20
|
+
| 'android_install_referrer'
|
|
21
|
+
| 'ios_user_paste'
|
|
22
|
+
| 'account_bound'
|
|
23
|
+
| 'unique_probabilistic'
|
|
24
|
+
| 'unmatched';
|
|
25
|
+
|
|
26
|
+
export type DeferredLinkCaptureBasis = Extract<
|
|
27
|
+
DeferredLinkMatchBasis,
|
|
28
|
+
'direct_token' | 'android_install_referrer' | 'ios_user_paste'
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
export type DeferredLinkStatus =
|
|
32
|
+
| 'idle'
|
|
33
|
+
| 'pending'
|
|
34
|
+
| 'resolving'
|
|
35
|
+
| 'waiting_for_account'
|
|
36
|
+
| 'retryable_error'
|
|
37
|
+
| 'applied'
|
|
38
|
+
| 'terminal_error';
|
|
39
|
+
|
|
40
|
+
export type DeferredLinkOutcomeName =
|
|
41
|
+
| 'app_open_confirmed'
|
|
42
|
+
| 'deferred_link_resolved'
|
|
43
|
+
| 'action_applied';
|
|
44
|
+
|
|
45
|
+
export interface ResolvedDeferredLink<Action extends string = DeferredLinkAction> {
|
|
46
|
+
id: string;
|
|
47
|
+
action?: Action;
|
|
48
|
+
deepLink?: string;
|
|
49
|
+
expiresAt?: string;
|
|
50
|
+
source?: string | null;
|
|
51
|
+
campaignId?: string | null;
|
|
52
|
+
experimentId?: string | null;
|
|
53
|
+
variantId?: string | null;
|
|
54
|
+
matchBasis: DeferredLinkMatchBasis;
|
|
55
|
+
matchGuaranteed: boolean;
|
|
56
|
+
confidence: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DeferredLinkPendingState {
|
|
60
|
+
/** Opaque bearer-by-entropy handoff. It is never logged or sent to Pulse event properties. */
|
|
61
|
+
token: string;
|
|
62
|
+
receivedAt: number;
|
|
63
|
+
matchBasis: DeferredLinkCaptureBasis;
|
|
64
|
+
attempts: number;
|
|
65
|
+
nextRetryAt: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DeferredLinkFirstOpenState {
|
|
69
|
+
/** Random retry nonce scoped to one install attempt; it is not a device or account identity. */
|
|
70
|
+
installAttemptId: string | null;
|
|
71
|
+
completed: boolean;
|
|
72
|
+
attemptedAt: number;
|
|
73
|
+
attempts: number;
|
|
74
|
+
nextRetryAt: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface DeferredLinkState {
|
|
78
|
+
status: DeferredLinkStatus;
|
|
79
|
+
pending: DeferredLinkPendingState | null;
|
|
80
|
+
lastAppliedId: string | null;
|
|
81
|
+
/** Bounded persisted idempotence window. */
|
|
82
|
+
appliedIds: readonly string[];
|
|
83
|
+
firstOpen: DeferredLinkFirstOpenState;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface DeferredLinkAccountBridge<Action extends string = DeferredLinkAction> {
|
|
87
|
+
claim(token?: string): Promise<ResolvedDeferredLink<Action> | null>;
|
|
88
|
+
pending(): Promise<ResolvedDeferredLink<Action> | null>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface DeferredLinkOutcomeEvent {
|
|
92
|
+
name: DeferredLinkOutcomeName;
|
|
93
|
+
matchBasis: DeferredLinkMatchBasis;
|
|
94
|
+
confidence: number;
|
|
95
|
+
campaignId?: string | null;
|
|
96
|
+
experimentId?: string | null;
|
|
97
|
+
variantId?: string | null;
|
|
98
|
+
occurredAt: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface AnonymousFirstOpenContext {
|
|
102
|
+
appBundleId: string;
|
|
103
|
+
locale: string;
|
|
104
|
+
platform: 'ios';
|
|
105
|
+
/** Native first-install timestamp. Matching is refused when recency cannot be proven. */
|
|
106
|
+
installedAt: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type AnonymousFirstOpenResult =
|
|
110
|
+
| 'matched'
|
|
111
|
+
| 'no_match'
|
|
112
|
+
| 'ineligible'
|
|
113
|
+
| 'deterministic_pending'
|
|
114
|
+
| 'already_completed'
|
|
115
|
+
| 'backoff'
|
|
116
|
+
| 'retry_scheduled'
|
|
117
|
+
| 'terminal_error';
|
|
118
|
+
|
|
119
|
+
export type AndroidInstallReferrerStatus =
|
|
120
|
+
| 'OK'
|
|
121
|
+
| 'NO_TOKEN'
|
|
122
|
+
| 'FEATURE_NOT_SUPPORTED'
|
|
123
|
+
| 'SERVICE_UNAVAILABLE'
|
|
124
|
+
| 'DEVELOPER_ERROR'
|
|
125
|
+
| 'SERVICE_DISCONNECTED';
|
|
126
|
+
|
|
127
|
+
export interface AndroidInstallReferrerResult {
|
|
128
|
+
status: AndroidInstallReferrerStatus;
|
|
129
|
+
retryable: boolean;
|
|
130
|
+
attempts: number;
|
|
131
|
+
token?: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Implemented by the separate native `PulseAttribution` module. */
|
|
135
|
+
export interface AndroidInstallReferrerBridge {
|
|
136
|
+
getDeferredHandoff(): Promise<unknown>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface DeferredLinkClientOptions<Action extends string = DeferredLinkAction> {
|
|
140
|
+
/** Public Encore resolver prefix for phase one, e.g. https://encore.example/t/d/. */
|
|
141
|
+
resolverBaseUrl: string;
|
|
142
|
+
/** App-owned schemes/universal-link prefixes accepted from the resolver. */
|
|
143
|
+
allowedDeepLinkPrefixes: readonly string[];
|
|
144
|
+
/** Closed action vocabulary. Unknown server actions are ignored. */
|
|
145
|
+
allowedActions?: readonly Action[];
|
|
146
|
+
/** Actions that require both a deterministic match and a stable signed-in account. */
|
|
147
|
+
sensitiveActions?: readonly Action[];
|
|
148
|
+
onDeepLink: (deepLink: string, link: ResolvedDeferredLink<Action>) => boolean | void | Promise<boolean | void>;
|
|
149
|
+
onAction?: (link: ResolvedDeferredLink<Action>) => boolean | void | Promise<boolean | void>;
|
|
150
|
+
accountBridge?: DeferredLinkAccountBridge<Action>;
|
|
151
|
+
isAccountReady?: () => boolean;
|
|
152
|
+
/** Lets the client retry an account-bound handoff immediately after login/logout changes. */
|
|
153
|
+
subscribeAccountState?: (listener: () => void) => (() => void);
|
|
154
|
+
/** Same synchronous storage adapter used by initPulse/MMKV. */
|
|
155
|
+
storage?: ConfigStorage;
|
|
156
|
+
/** Used to derive `pulse.{appSlug}.links.v1`; required with storage unless storageKey is explicit. */
|
|
157
|
+
appSlug?: string;
|
|
158
|
+
/** Must be app-scoped when storage is shared by more than one app. */
|
|
159
|
+
storageKey?: string;
|
|
160
|
+
fetch?: typeof fetch;
|
|
161
|
+
requestTimeoutMs?: number;
|
|
162
|
+
retryBaseMs?: number;
|
|
163
|
+
retryMaxMs?: number;
|
|
164
|
+
tokenMaxAgeMs?: number;
|
|
165
|
+
recentInstallMaxAgeMs?: number;
|
|
166
|
+
autoRetry?: boolean;
|
|
167
|
+
reportResolverOutcomes?: boolean;
|
|
168
|
+
onOutcome?: (event: DeferredLinkOutcomeEvent) => void | Promise<void>;
|
|
169
|
+
onStateChange?: (state: DeferredLinkState) => void;
|
|
170
|
+
onError?: (error: unknown) => void;
|
|
171
|
+
now?: () => number;
|
|
172
|
+
randomUUID?: () => string;
|
|
173
|
+
/** Phase-one public campaign exposures are 128-bit hex; account tokens use the wider grammar. */
|
|
174
|
+
isPublicToken?: (token: string) => boolean;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface DeferredLinkClient {
|
|
178
|
+
capture(token: string, basis: DeferredLinkCaptureBasis): boolean;
|
|
179
|
+
captureUrl(url: string): boolean;
|
|
180
|
+
process(): Promise<void>;
|
|
181
|
+
matchFirstOpen(context: AnonymousFirstOpenContext): Promise<AnonymousFirstOpenResult>;
|
|
182
|
+
captureAndroidInstallReferrer(bridge: AndroidInstallReferrerBridge): Promise<AndroidInstallReferrerResult>;
|
|
183
|
+
getState(): DeferredLinkState;
|
|
184
|
+
subscribe(listener: (state: DeferredLinkState) => void): () => void;
|
|
185
|
+
dispose(): void;
|
|
186
|
+
/** Clears only install-attribution state. It never touches Pulse config/events or account data. */
|
|
187
|
+
reset(): void;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const OPAQUE_TOKEN = /^[A-Za-z0-9_-]{16,512}$/;
|
|
191
|
+
const PUBLIC_EXPOSURE = /^[a-f0-9]{32}$/i;
|
|
192
|
+
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;
|
|
193
|
+
const APP_IDENTITY = /^[A-Za-z0-9.-]+$/;
|
|
194
|
+
const LOCALE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
195
|
+
const DEFAULT_PREFIXES = ['encore_handoff:', 'pulse_handoff:'] as const;
|
|
196
|
+
const DEFAULT_QUERY_KEYS = new Set([
|
|
197
|
+
'encore_handoff',
|
|
198
|
+
'encore_deferred',
|
|
199
|
+
'pulse_handoff',
|
|
200
|
+
'pulse_deferred',
|
|
201
|
+
]);
|
|
202
|
+
const MATCH_BASES = new Set<DeferredLinkMatchBasis>([
|
|
203
|
+
'direct_token',
|
|
204
|
+
'android_install_referrer',
|
|
205
|
+
'ios_user_paste',
|
|
206
|
+
'account_bound',
|
|
207
|
+
'unique_probabilistic',
|
|
208
|
+
'unmatched',
|
|
209
|
+
]);
|
|
210
|
+
const CAPTURE_BASES = new Set<DeferredLinkCaptureBasis>([
|
|
211
|
+
'direct_token',
|
|
212
|
+
'android_install_referrer',
|
|
213
|
+
'ios_user_paste',
|
|
214
|
+
]);
|
|
215
|
+
const STATUSES = new Set<DeferredLinkStatus>([
|
|
216
|
+
'idle',
|
|
217
|
+
'pending',
|
|
218
|
+
'resolving',
|
|
219
|
+
'waiting_for_account',
|
|
220
|
+
'retryable_error',
|
|
221
|
+
'applied',
|
|
222
|
+
'terminal_error',
|
|
223
|
+
]);
|
|
224
|
+
const INSTALL_REFERRER_STATUSES = new Set<AndroidInstallReferrerStatus>([
|
|
225
|
+
'OK',
|
|
226
|
+
'NO_TOKEN',
|
|
227
|
+
'FEATURE_NOT_SUPPORTED',
|
|
228
|
+
'SERVICE_UNAVAILABLE',
|
|
229
|
+
'DEVELOPER_ERROR',
|
|
230
|
+
'SERVICE_DISCONNECTED',
|
|
231
|
+
]);
|
|
232
|
+
const DEFAULT_ACTIONS = [
|
|
233
|
+
'open_home',
|
|
234
|
+
'open_premium',
|
|
235
|
+
'manage_subscription',
|
|
236
|
+
'refresh_entitlement',
|
|
237
|
+
] as const;
|
|
238
|
+
const DEFAULT_SENSITIVE_ACTIONS = ['manage_subscription', 'refresh_entitlement'] as const;
|
|
239
|
+
const DEFAULT_STORAGE_KEY = 'pulse.links.v1';
|
|
240
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
241
|
+
const DEFAULT_RETRY_BASE_MS = 15_000;
|
|
242
|
+
const DEFAULT_RETRY_MAX_MS = 6 * 60 * 60 * 1_000;
|
|
243
|
+
const DEFAULT_TOKEN_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1_000;
|
|
244
|
+
const DEFAULT_RECENT_INSTALL_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
|
|
245
|
+
const MAX_APPLIED_IDS = 32;
|
|
246
|
+
const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
|
|
247
|
+
const MAX_PERSISTED_BYTES = 16_384;
|
|
248
|
+
|
|
249
|
+
interface PersistedDeferredLinkState {
|
|
250
|
+
version: 1;
|
|
251
|
+
status: DeferredLinkStatus;
|
|
252
|
+
pending: DeferredLinkPendingState | null;
|
|
253
|
+
lastAppliedId: string | null;
|
|
254
|
+
appliedIds: string[];
|
|
255
|
+
/** Internal transition-once ledger of non-reversible bounded hashes. */
|
|
256
|
+
notifiedOutcomes: string[];
|
|
257
|
+
firstOpen: DeferredLinkFirstOpenState;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
type ResolveResult<Action extends string> =
|
|
261
|
+
| { kind: 'resolved'; link: ResolvedDeferredLink<Action> }
|
|
262
|
+
| { kind: 'not_found' }
|
|
263
|
+
| { kind: 'retryable' };
|
|
264
|
+
|
|
265
|
+
const emptyState = (): PersistedDeferredLinkState => ({
|
|
266
|
+
version: 1,
|
|
267
|
+
status: 'idle',
|
|
268
|
+
pending: null,
|
|
269
|
+
lastAppliedId: null,
|
|
270
|
+
appliedIds: [],
|
|
271
|
+
notifiedOutcomes: [],
|
|
272
|
+
firstOpen: {
|
|
273
|
+
installAttemptId: null,
|
|
274
|
+
completed: false,
|
|
275
|
+
attemptedAt: 0,
|
|
276
|
+
attempts: 0,
|
|
277
|
+
nextRetryAt: 0,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
export function isOpaqueDeferredLinkToken(value: unknown): value is string {
|
|
282
|
+
return typeof value === 'string' && OPAQUE_TOKEN.test(value);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function normalizeDeferredHandoffToken(
|
|
286
|
+
raw: string | null | undefined,
|
|
287
|
+
acceptedPrefixes: readonly string[] = DEFAULT_PREFIXES,
|
|
288
|
+
): string | null {
|
|
289
|
+
if (typeof raw !== 'string') return null;
|
|
290
|
+
let candidate = raw.trim();
|
|
291
|
+
const lower = candidate.toLowerCase();
|
|
292
|
+
const prefix = acceptedPrefixes.find((value) => {
|
|
293
|
+
const normalized = value.trim().toLowerCase();
|
|
294
|
+
return normalized.length > 1 && lower.startsWith(normalized);
|
|
295
|
+
});
|
|
296
|
+
if (prefix) candidate = candidate.slice(prefix.trim().length);
|
|
297
|
+
return isOpaqueDeferredLinkToken(candidate) ? candidate : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Extracts only explicit handoff query keys or /handoff|d/{token} path segments. */
|
|
301
|
+
export function extractDeferredHandoffToken(url: string): string | null {
|
|
302
|
+
if (typeof url !== 'string' || url.length === 0 || url.length > 4_096) return null;
|
|
303
|
+
let parsed: URL;
|
|
304
|
+
try {
|
|
305
|
+
parsed = new URL(url);
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
if (!parsed.protocol) return null;
|
|
310
|
+
|
|
311
|
+
const queryCandidates: string[] = [];
|
|
312
|
+
for (const [key, value] of parsed.searchParams.entries()) {
|
|
313
|
+
if (DEFAULT_QUERY_KEYS.has(key.toLowerCase())) queryCandidates.push(value);
|
|
314
|
+
}
|
|
315
|
+
if (queryCandidates.length > 1) return null;
|
|
316
|
+
if (queryCandidates.length === 1) return normalizeDeferredHandoffToken(queryCandidates[0]);
|
|
317
|
+
|
|
318
|
+
const segments = [parsed.hostname, ...parsed.pathname.split('/')].filter(Boolean);
|
|
319
|
+
for (let index = 0; index < segments.length - 1; index += 1) {
|
|
320
|
+
const marker = segments[index]?.toLowerCase();
|
|
321
|
+
if (marker !== 'handoff' && marker !== 'd') continue;
|
|
322
|
+
try {
|
|
323
|
+
return normalizeDeferredHandoffToken(decodeURIComponent(segments[index + 1] ?? ''));
|
|
324
|
+
} catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Runtime-validates the native result so raw bridge data can never leak into app state. */
|
|
332
|
+
export function normalizeAndroidInstallReferrerResult(raw: unknown): AndroidInstallReferrerResult {
|
|
333
|
+
if (!raw || typeof raw !== 'object') {
|
|
334
|
+
return { status: 'SERVICE_UNAVAILABLE', retryable: true, attempts: 0 };
|
|
335
|
+
}
|
|
336
|
+
const value = raw as Record<string, unknown>;
|
|
337
|
+
const status = typeof value.status === 'string'
|
|
338
|
+
&& INSTALL_REFERRER_STATUSES.has(value.status as AndroidInstallReferrerStatus)
|
|
339
|
+
? value.status as AndroidInstallReferrerStatus
|
|
340
|
+
: 'DEVELOPER_ERROR';
|
|
341
|
+
const attempts = typeof value.attempts === 'number' && Number.isFinite(value.attempts)
|
|
342
|
+
? Math.max(0, Math.min(3, Math.trunc(value.attempts)))
|
|
343
|
+
: 0;
|
|
344
|
+
const expectedRetryable = status === 'SERVICE_UNAVAILABLE' || status === 'SERVICE_DISCONNECTED';
|
|
345
|
+
const token = status === 'OK' ? normalizeDeferredHandoffToken(value.token as string | undefined) : null;
|
|
346
|
+
if (status === 'OK' && !token) return { status: 'NO_TOKEN', retryable: false, attempts };
|
|
347
|
+
return {
|
|
348
|
+
status,
|
|
349
|
+
retryable: expectedRetryable,
|
|
350
|
+
attempts,
|
|
351
|
+
...(token ? { token } : {}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function createPulseLinkClient<Action extends string = DeferredLinkAction>(
|
|
356
|
+
options: DeferredLinkClientOptions<Action>,
|
|
357
|
+
): DeferredLinkClient {
|
|
358
|
+
const resolverBaseUrl = normalizeBaseUrl(options.resolverBaseUrl);
|
|
359
|
+
if (!/^https:\/\//i.test(resolverBaseUrl)) {
|
|
360
|
+
throw new Error('Pulse Links: resolverBaseUrl must use HTTPS');
|
|
361
|
+
}
|
|
362
|
+
const allowedDeepLinkPrefixes = options.allowedDeepLinkPrefixes
|
|
363
|
+
.map((prefix) => prefix.trim().toLowerCase())
|
|
364
|
+
.filter((prefix) => prefix.length > 0 && prefix.length <= 256);
|
|
365
|
+
if (allowedDeepLinkPrefixes.length === 0) {
|
|
366
|
+
throw new Error('Pulse Links: at least one app-owned deep-link prefix is required');
|
|
367
|
+
}
|
|
368
|
+
const allowedActions = new Set<Action>(
|
|
369
|
+
(options.allowedActions ?? DEFAULT_ACTIONS as unknown as readonly Action[]).filter(Boolean),
|
|
370
|
+
);
|
|
371
|
+
const sensitiveActions = new Set<Action>(
|
|
372
|
+
(options.sensitiveActions ?? DEFAULT_SENSITIVE_ACTIONS as unknown as readonly Action[]).filter(Boolean),
|
|
373
|
+
);
|
|
374
|
+
const now = options.now ?? Date.now;
|
|
375
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
376
|
+
const appSlug = normalizeAppSlug(options.appSlug);
|
|
377
|
+
const explicitStorageKey = options.storageKey?.trim();
|
|
378
|
+
if (options.storage && !explicitStorageKey && !appSlug) {
|
|
379
|
+
throw new Error('Pulse Links: appSlug or an app-scoped storageKey is required when storage is used');
|
|
380
|
+
}
|
|
381
|
+
const storageKey = explicitStorageKey || (appSlug ? `pulse.${appSlug}.links.v1` : DEFAULT_STORAGE_KEY);
|
|
382
|
+
const requestTimeoutMs = boundedDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 500, 60_000);
|
|
383
|
+
const retryBaseMs = boundedDuration(options.retryBaseMs, DEFAULT_RETRY_BASE_MS, 100, 60 * 60 * 1_000);
|
|
384
|
+
const retryMaxMs = boundedDuration(options.retryMaxMs, DEFAULT_RETRY_MAX_MS, retryBaseMs, 24 * 60 * 60 * 1_000);
|
|
385
|
+
const tokenMaxAgeMs = boundedDuration(options.tokenMaxAgeMs, DEFAULT_TOKEN_MAX_AGE_MS, 60_000, 365 * 24 * 60 * 60 * 1_000);
|
|
386
|
+
const recentInstallMaxAgeMs = boundedDuration(
|
|
387
|
+
options.recentInstallMaxAgeMs,
|
|
388
|
+
DEFAULT_RECENT_INSTALL_MAX_AGE_MS,
|
|
389
|
+
60_000,
|
|
390
|
+
7 * 24 * 60 * 60 * 1_000,
|
|
391
|
+
);
|
|
392
|
+
const isPublicToken = options.isPublicToken ?? ((token: string) => PUBLIC_EXPOSURE.test(token));
|
|
393
|
+
let state = readState(options.storage, storageKey);
|
|
394
|
+
let disposed = false;
|
|
395
|
+
let processing: Promise<void> | null = null;
|
|
396
|
+
let processRequested = false;
|
|
397
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
398
|
+
let accountRetryAt = 0;
|
|
399
|
+
let accountAttempts = 0;
|
|
400
|
+
let lastFirstOpenContext: AnonymousFirstOpenContext | null = null;
|
|
401
|
+
let unsubscribeAccount: (() => void) | null = null;
|
|
402
|
+
const listeners = new Set<(value: DeferredLinkState) => void>();
|
|
403
|
+
|
|
404
|
+
const reportError = (error: unknown): void => {
|
|
405
|
+
try { options.onError?.(error); } catch { /* diagnostic hooks never break link handling */ }
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const snapshot = (): DeferredLinkState => ({
|
|
409
|
+
status: state.status,
|
|
410
|
+
pending: state.pending ? { ...state.pending } : null,
|
|
411
|
+
lastAppliedId: state.lastAppliedId,
|
|
412
|
+
appliedIds: [...state.appliedIds],
|
|
413
|
+
firstOpen: { ...state.firstOpen },
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const persistAndNotify = (): void => {
|
|
417
|
+
writeState(options.storage, storageKey, state);
|
|
418
|
+
const value = snapshot();
|
|
419
|
+
for (const listener of listeners) {
|
|
420
|
+
try { listener(value); } catch (error) { reportError(error); }
|
|
421
|
+
}
|
|
422
|
+
try { options.onStateChange?.(value); } catch (error) { reportError(error); }
|
|
423
|
+
scheduleWake();
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const patchState = (patch: Partial<PersistedDeferredLinkState>): void => {
|
|
427
|
+
state = { ...state, ...patch };
|
|
428
|
+
persistAndNotify();
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const patchFirstOpen = (patch: Partial<DeferredLinkFirstOpenState>): void => {
|
|
432
|
+
state = { ...state, firstOpen: { ...state.firstOpen, ...patch } };
|
|
433
|
+
persistAndNotify();
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const isAccountReady = (): boolean => {
|
|
437
|
+
try { return options.isAccountReady?.() ?? false; } catch (error) {
|
|
438
|
+
reportError(error);
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const schedulePendingRetry = (): void => {
|
|
444
|
+
const pending = state.pending;
|
|
445
|
+
if (!pending) return;
|
|
446
|
+
const attempts = pending.attempts + 1;
|
|
447
|
+
const delay = Math.min(retryBaseMs * 2 ** Math.min(attempts - 1, 10), retryMaxMs);
|
|
448
|
+
state = {
|
|
449
|
+
...state,
|
|
450
|
+
status: 'retryable_error',
|
|
451
|
+
pending: { ...pending, attempts, nextRetryAt: now() + delay },
|
|
452
|
+
};
|
|
453
|
+
persistAndNotify();
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const scheduleFirstOpenRetry = (): void => {
|
|
457
|
+
const attempts = state.firstOpen.attempts + 1;
|
|
458
|
+
const delay = Math.min(retryBaseMs * 2 ** Math.min(attempts - 1, 10), retryMaxMs);
|
|
459
|
+
state = {
|
|
460
|
+
...state,
|
|
461
|
+
status: 'retryable_error',
|
|
462
|
+
firstOpen: { ...state.firstOpen, attempts, nextRetryAt: now() + delay },
|
|
463
|
+
};
|
|
464
|
+
persistAndNotify();
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const scheduleAccountRetry = (): void => {
|
|
468
|
+
accountAttempts += 1;
|
|
469
|
+
const delay = Math.min(retryBaseMs * 2 ** Math.min(accountAttempts - 1, 10), retryMaxMs);
|
|
470
|
+
accountRetryAt = now() + delay;
|
|
471
|
+
patchState({ status: 'retryable_error' });
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const clearPendingAsTerminal = (): void => {
|
|
475
|
+
patchState({ pending: null, status: 'terminal_error' });
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const emitOutcome = (link: ResolvedDeferredLink<Action>, name: DeferredLinkOutcomeName): void => {
|
|
479
|
+
const transitionKey = stableOutcomeKey(link.id, name);
|
|
480
|
+
if (!state.notifiedOutcomes.includes(transitionKey)) {
|
|
481
|
+
// Persist before handing off to the general Pulse outbox. A process death after the callback
|
|
482
|
+
// queues an event must not queue the same product transition again on restart.
|
|
483
|
+
state = {
|
|
484
|
+
...state,
|
|
485
|
+
notifiedOutcomes: [...state.notifiedOutcomes, transitionKey].slice(-MAX_NOTIFIED_OUTCOMES),
|
|
486
|
+
};
|
|
487
|
+
writeState(options.storage, storageKey, state);
|
|
488
|
+
const event: DeferredLinkOutcomeEvent = {
|
|
489
|
+
name,
|
|
490
|
+
matchBasis: link.matchBasis,
|
|
491
|
+
confidence: link.confidence,
|
|
492
|
+
...(link.campaignId !== undefined ? { campaignId: link.campaignId } : {}),
|
|
493
|
+
...(link.experimentId !== undefined ? { experimentId: link.experimentId } : {}),
|
|
494
|
+
...(link.variantId !== undefined ? { variantId: link.variantId } : {}),
|
|
495
|
+
occurredAt: new Date(now()).toISOString(),
|
|
496
|
+
};
|
|
497
|
+
try {
|
|
498
|
+
const result = options.onOutcome?.(event);
|
|
499
|
+
if (result && typeof (result as Promise<void>).catch === 'function') {
|
|
500
|
+
void (result as Promise<void>).catch(reportError);
|
|
501
|
+
}
|
|
502
|
+
} catch (error) {
|
|
503
|
+
reportError(error);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if ((options.reportResolverOutcomes ?? true) && isPublicToken(link.id)) {
|
|
508
|
+
void postResolverOutcome(
|
|
509
|
+
fetcher,
|
|
510
|
+
resolverBaseUrl,
|
|
511
|
+
link,
|
|
512
|
+
name,
|
|
513
|
+
requestTimeoutMs,
|
|
514
|
+
).catch(reportError);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const applyResolved = async (
|
|
519
|
+
link: ResolvedDeferredLink<Action>,
|
|
520
|
+
sourceToken: string | null,
|
|
521
|
+
): Promise<boolean> => {
|
|
522
|
+
if (disposed) return false;
|
|
523
|
+
if (state.appliedIds.includes(link.id)) {
|
|
524
|
+
if (sourceToken && state.pending?.token === sourceToken) {
|
|
525
|
+
state = {
|
|
526
|
+
...state,
|
|
527
|
+
status: 'applied',
|
|
528
|
+
pending: null,
|
|
529
|
+
appliedIds: [...state.appliedIds.filter((id) => id !== sourceToken), sourceToken]
|
|
530
|
+
.slice(-MAX_APPLIED_IDS),
|
|
531
|
+
};
|
|
532
|
+
persistAndNotify();
|
|
533
|
+
}
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
if (sourceToken && state.pending?.token !== sourceToken) return false;
|
|
537
|
+
if (link.matchBasis === 'unmatched') return false;
|
|
538
|
+
|
|
539
|
+
if (link.action && sensitiveActions.has(link.action)) {
|
|
540
|
+
if (!link.matchGuaranteed) {
|
|
541
|
+
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
if (!isAccountReady()) {
|
|
545
|
+
patchState({ status: 'waiting_for_account' });
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Validate the whole product surface before measuring a successful resolution. Otherwise a
|
|
551
|
+
// malicious/out-of-contract URL would inflate the resolver funnel despite never being usable.
|
|
552
|
+
if (link.deepLink && !isAllowedDeepLink(link.deepLink, allowedDeepLinkPrefixes)) {
|
|
553
|
+
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
if (!link.deepLink && (!link.action || !options.onAction)) {
|
|
557
|
+
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
emitOutcome(link, 'app_open_confirmed');
|
|
562
|
+
emitOutcome(link, 'deferred_link_resolved');
|
|
563
|
+
let applied: boolean | void;
|
|
564
|
+
try {
|
|
565
|
+
if (link.deepLink) {
|
|
566
|
+
applied = await options.onDeepLink(link.deepLink, link);
|
|
567
|
+
} else if (link.action && options.onAction) {
|
|
568
|
+
applied = await options.onAction(link);
|
|
569
|
+
} else {
|
|
570
|
+
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
} catch (error) {
|
|
574
|
+
reportError(error);
|
|
575
|
+
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();
|
|
576
|
+
else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();
|
|
577
|
+
else scheduleAccountRetry();
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
if (applied === false) {
|
|
581
|
+
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();
|
|
582
|
+
else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();
|
|
583
|
+
else scheduleAccountRetry();
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const newlyApplied = sourceToken && sourceToken !== link.id ? [link.id, sourceToken] : [link.id];
|
|
588
|
+
const newlyAppliedSet = new Set(newlyApplied);
|
|
589
|
+
const appliedIds = [...state.appliedIds.filter((id) => !newlyAppliedSet.has(id)), ...newlyApplied]
|
|
590
|
+
.slice(-MAX_APPLIED_IDS);
|
|
591
|
+
const stillPending = sourceToken !== null && state.pending?.token !== sourceToken;
|
|
592
|
+
state = {
|
|
593
|
+
...state,
|
|
594
|
+
status: stillPending ? 'pending' : 'applied',
|
|
595
|
+
pending: sourceToken && state.pending?.token === sourceToken ? null : state.pending,
|
|
596
|
+
lastAppliedId: link.id,
|
|
597
|
+
appliedIds,
|
|
598
|
+
};
|
|
599
|
+
persistAndNotify();
|
|
600
|
+
if (link.matchBasis === 'account_bound') {
|
|
601
|
+
accountAttempts = 0;
|
|
602
|
+
accountRetryAt = 0;
|
|
603
|
+
}
|
|
604
|
+
emitOutcome(link, 'action_applied');
|
|
605
|
+
if (stillPending) void process();
|
|
606
|
+
return true;
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const resolvePublic = async (
|
|
610
|
+
token: string,
|
|
611
|
+
basis: DeferredLinkMatchBasis,
|
|
612
|
+
): Promise<ResolveResult<Action>> => {
|
|
613
|
+
if (!isPublicToken(token)) return { kind: 'not_found' };
|
|
614
|
+
try {
|
|
615
|
+
const response = await withTimeout(
|
|
616
|
+
fetcher,
|
|
617
|
+
`${resolverBaseUrl}${encodeURIComponent(token)}`,
|
|
618
|
+
{
|
|
619
|
+
method: 'GET',
|
|
620
|
+
headers: { Accept: 'application/json' },
|
|
621
|
+
},
|
|
622
|
+
requestTimeoutMs,
|
|
623
|
+
);
|
|
624
|
+
if (response.status === 404 || response.status === 410) return { kind: 'not_found' };
|
|
625
|
+
if (!response.ok) {
|
|
626
|
+
return { kind: response.status === 429 || response.status >= 500 ? 'retryable' : 'not_found' };
|
|
627
|
+
}
|
|
628
|
+
const link = normalizeResolved<Action>(
|
|
629
|
+
await response.json(),
|
|
630
|
+
token,
|
|
631
|
+
basis,
|
|
632
|
+
allowedActions,
|
|
633
|
+
now(),
|
|
634
|
+
);
|
|
635
|
+
return link ? { kind: 'resolved', link } : { kind: 'not_found' };
|
|
636
|
+
} catch {
|
|
637
|
+
return { kind: 'retryable' };
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const processOnce = async (): Promise<void> => {
|
|
642
|
+
if (disposed) return;
|
|
643
|
+
const pending = state.pending;
|
|
644
|
+
if (pending) {
|
|
645
|
+
if (pending.nextRetryAt > now()) return;
|
|
646
|
+
if (now() - pending.receivedAt > tokenMaxAgeMs) {
|
|
647
|
+
clearPendingAsTerminal();
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const token = pending.token;
|
|
651
|
+
patchState({ status: 'resolving' });
|
|
652
|
+
const publicResult = await resolvePublic(token, pending.matchBasis);
|
|
653
|
+
if (disposed || state.pending?.token !== token) return;
|
|
654
|
+
if (publicResult.kind === 'resolved') {
|
|
655
|
+
await applyResolved(publicResult.link, token);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (publicResult.kind === 'retryable') {
|
|
659
|
+
schedulePendingRetry();
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (!options.accountBridge || !isAccountReady()) {
|
|
664
|
+
patchState({ status: 'waiting_for_account' });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
const raw = await options.accountBridge.claim(token);
|
|
669
|
+
if (disposed || state.pending?.token !== token) return;
|
|
670
|
+
const normalizedClaim = normalizeResolved<Action>(
|
|
671
|
+
raw,
|
|
672
|
+
token,
|
|
673
|
+
'account_bound',
|
|
674
|
+
allowedActions,
|
|
675
|
+
now(),
|
|
676
|
+
);
|
|
677
|
+
const claimed = normalizedClaim ? {
|
|
678
|
+
...normalizedClaim,
|
|
679
|
+
matchBasis: 'account_bound' as const,
|
|
680
|
+
matchGuaranteed: true,
|
|
681
|
+
confidence: 1,
|
|
682
|
+
} : null;
|
|
683
|
+
if (claimed) await applyResolved(claimed, token);
|
|
684
|
+
else clearPendingAsTerminal();
|
|
685
|
+
} catch (error) {
|
|
686
|
+
reportError(error);
|
|
687
|
+
if (state.pending?.token === token) schedulePendingRetry();
|
|
688
|
+
}
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (!options.accountBridge || !isAccountReady() || accountRetryAt > now()) return;
|
|
693
|
+
try {
|
|
694
|
+
const raw = await options.accountBridge.pending();
|
|
695
|
+
if (!raw) {
|
|
696
|
+
accountAttempts = 0;
|
|
697
|
+
accountRetryAt = 0;
|
|
698
|
+
if (state.status === 'waiting_for_account' || state.status === 'retryable_error') {
|
|
699
|
+
patchState({ status: 'idle' });
|
|
700
|
+
}
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const fallbackId = raw && typeof raw === 'object' && typeof raw.id === 'string'
|
|
704
|
+
? raw.id
|
|
705
|
+
: 'account-bound-pending';
|
|
706
|
+
const normalizedPending = normalizeResolved<Action>(
|
|
707
|
+
raw,
|
|
708
|
+
fallbackId,
|
|
709
|
+
'account_bound',
|
|
710
|
+
allowedActions,
|
|
711
|
+
now(),
|
|
712
|
+
);
|
|
713
|
+
const pendingLink = normalizedPending ? {
|
|
714
|
+
...normalizedPending,
|
|
715
|
+
matchBasis: 'account_bound' as const,
|
|
716
|
+
matchGuaranteed: true,
|
|
717
|
+
confidence: 1,
|
|
718
|
+
} : null;
|
|
719
|
+
if (pendingLink && !state.appliedIds.includes(pendingLink.id)) {
|
|
720
|
+
await applyResolved(pendingLink, null);
|
|
721
|
+
} else if (pendingLink && state.status !== 'applied') {
|
|
722
|
+
patchState({ status: 'applied' });
|
|
723
|
+
}
|
|
724
|
+
} catch (error) {
|
|
725
|
+
reportError(error);
|
|
726
|
+
// No token was consumed; the authenticated server outbox remains authoritative.
|
|
727
|
+
scheduleAccountRetry();
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const process = async (): Promise<void> => {
|
|
732
|
+
if (disposed) return;
|
|
733
|
+
processRequested = true;
|
|
734
|
+
if (processing) return processing;
|
|
735
|
+
const run = (async () => {
|
|
736
|
+
do {
|
|
737
|
+
processRequested = false;
|
|
738
|
+
await processOnce();
|
|
739
|
+
} while (processRequested && !disposed);
|
|
740
|
+
})();
|
|
741
|
+
let ownedPromise: Promise<void>;
|
|
742
|
+
ownedPromise = run
|
|
743
|
+
.catch(reportError)
|
|
744
|
+
.then(async () => {
|
|
745
|
+
if (processing === ownedPromise) processing = null;
|
|
746
|
+
// A capture can arrive after the drain's last condition check but before its promise
|
|
747
|
+
// continuation runs. Chain that late request instead of losing the wakeup.
|
|
748
|
+
if (processRequested && !disposed) await process();
|
|
749
|
+
});
|
|
750
|
+
processing = ownedPromise;
|
|
751
|
+
return ownedPromise;
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const capture = (rawToken: string, basis: DeferredLinkCaptureBasis): boolean => {
|
|
755
|
+
if (disposed || !CAPTURE_BASES.has(basis)) return false;
|
|
756
|
+
const token = normalizeDeferredHandoffToken(rawToken);
|
|
757
|
+
if (!token) return false;
|
|
758
|
+
if (state.appliedIds.includes(token)) {
|
|
759
|
+
if (!state.firstOpen.completed) patchFirstOpen({ completed: true, nextRetryAt: 0 });
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
if (state.pending?.token === token) {
|
|
763
|
+
if (state.pending.matchBasis === 'direct_token' && basis !== 'direct_token') {
|
|
764
|
+
state = {
|
|
765
|
+
...state,
|
|
766
|
+
pending: { ...state.pending, matchBasis: basis },
|
|
767
|
+
firstOpen: { ...state.firstOpen, completed: true, nextRetryAt: 0 },
|
|
768
|
+
};
|
|
769
|
+
persistAndNotify();
|
|
770
|
+
} else if (!state.firstOpen.completed) {
|
|
771
|
+
patchFirstOpen({ completed: true, nextRetryAt: 0 });
|
|
772
|
+
}
|
|
773
|
+
void process();
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
state = {
|
|
777
|
+
...state,
|
|
778
|
+
status: 'pending',
|
|
779
|
+
pending: {
|
|
780
|
+
token,
|
|
781
|
+
receivedAt: now(),
|
|
782
|
+
matchBasis: basis,
|
|
783
|
+
attempts: 0,
|
|
784
|
+
nextRetryAt: 0,
|
|
785
|
+
},
|
|
786
|
+
firstOpen: {
|
|
787
|
+
...state.firstOpen,
|
|
788
|
+
completed: true,
|
|
789
|
+
nextRetryAt: 0,
|
|
790
|
+
},
|
|
791
|
+
};
|
|
792
|
+
persistAndNotify();
|
|
793
|
+
void process();
|
|
794
|
+
return true;
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const matchFirstOpen = async (
|
|
798
|
+
context: AnonymousFirstOpenContext,
|
|
799
|
+
): Promise<AnonymousFirstOpenResult> => {
|
|
800
|
+
if (disposed) return 'ineligible';
|
|
801
|
+
lastFirstOpenContext = context;
|
|
802
|
+
if (state.pending) return 'deterministic_pending';
|
|
803
|
+
if (state.firstOpen.completed) return 'already_completed';
|
|
804
|
+
if (state.firstOpen.nextRetryAt > now()) return 'backoff';
|
|
805
|
+
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
806
|
+
|
|
807
|
+
const installAttemptId = state.firstOpen.installAttemptId
|
|
808
|
+
?? makeInstallAttemptId(options.randomUUID);
|
|
809
|
+
state = {
|
|
810
|
+
...state,
|
|
811
|
+
status: 'resolving',
|
|
812
|
+
firstOpen: {
|
|
813
|
+
...state.firstOpen,
|
|
814
|
+
installAttemptId,
|
|
815
|
+
attemptedAt: now(),
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
persistAndNotify();
|
|
819
|
+
|
|
820
|
+
const body = {
|
|
821
|
+
appBundleId: context.appBundleId.trim(),
|
|
822
|
+
platform: 'ios' as const,
|
|
823
|
+
locale: normalizeLocale(context.locale),
|
|
824
|
+
firstOpen: true,
|
|
825
|
+
installAttemptId,
|
|
826
|
+
};
|
|
827
|
+
try {
|
|
828
|
+
const response = await withTimeout(
|
|
829
|
+
fetcher,
|
|
830
|
+
matchUrlOf(resolverBaseUrl),
|
|
831
|
+
{
|
|
832
|
+
method: 'POST',
|
|
833
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
834
|
+
body: JSON.stringify(body),
|
|
835
|
+
},
|
|
836
|
+
requestTimeoutMs,
|
|
837
|
+
);
|
|
838
|
+
if (state.pending) return 'deterministic_pending';
|
|
839
|
+
if (state.firstOpen.completed) return 'deterministic_pending';
|
|
840
|
+
if (response.status === 204) {
|
|
841
|
+
patchFirstOpen({ completed: true, attempts: 0, nextRetryAt: 0 });
|
|
842
|
+
patchState({ status: 'idle' });
|
|
843
|
+
return 'no_match';
|
|
844
|
+
}
|
|
845
|
+
if (response.status === 400 || response.status === 404 || response.status === 410) {
|
|
846
|
+
patchFirstOpen({ completed: true, nextRetryAt: 0 });
|
|
847
|
+
patchState({ status: 'terminal_error' });
|
|
848
|
+
return 'terminal_error';
|
|
849
|
+
}
|
|
850
|
+
if (!response.ok) {
|
|
851
|
+
if (response.status === 429 || response.status >= 500) {
|
|
852
|
+
scheduleFirstOpenRetry();
|
|
853
|
+
return 'retry_scheduled';
|
|
854
|
+
}
|
|
855
|
+
patchFirstOpen({ completed: true, nextRetryAt: 0 });
|
|
856
|
+
patchState({ status: 'terminal_error' });
|
|
857
|
+
return 'terminal_error';
|
|
858
|
+
}
|
|
859
|
+
const link = normalizeResolved<Action>(
|
|
860
|
+
await response.json(),
|
|
861
|
+
installAttemptId.replace(/-/g, ''),
|
|
862
|
+
'unique_probabilistic',
|
|
863
|
+
allowedActions,
|
|
864
|
+
now(),
|
|
865
|
+
);
|
|
866
|
+
// The probabilistic endpoint can personalize harmless UX only. It can never upgrade its own
|
|
867
|
+
// evidence to guaranteed or return a deterministic/account basis.
|
|
868
|
+
if (!link || link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed
|
|
869
|
+
|| (link.action && sensitiveActions.has(link.action))) {
|
|
870
|
+
patchFirstOpen({ completed: true, nextRetryAt: 0 });
|
|
871
|
+
patchState({ status: 'terminal_error' });
|
|
872
|
+
return 'terminal_error';
|
|
873
|
+
}
|
|
874
|
+
if (await applyResolved(link, null)) {
|
|
875
|
+
patchFirstOpen({ completed: true, attempts: 0, nextRetryAt: 0 });
|
|
876
|
+
return 'matched';
|
|
877
|
+
}
|
|
878
|
+
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
879
|
+
return 'retry_scheduled';
|
|
880
|
+
} catch (error) {
|
|
881
|
+
reportError(error);
|
|
882
|
+
scheduleFirstOpenRetry();
|
|
883
|
+
return 'retry_scheduled';
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
const captureAndroidInstallReferrer = async (
|
|
888
|
+
bridge: AndroidInstallReferrerBridge,
|
|
889
|
+
): Promise<AndroidInstallReferrerResult> => {
|
|
890
|
+
let raw: unknown;
|
|
891
|
+
try {
|
|
892
|
+
raw = await bridge.getDeferredHandoff();
|
|
893
|
+
} catch (error) {
|
|
894
|
+
reportError(error);
|
|
895
|
+
return { status: 'SERVICE_UNAVAILABLE', retryable: true, attempts: 0 };
|
|
896
|
+
}
|
|
897
|
+
const result = normalizeAndroidInstallReferrerResult(raw);
|
|
898
|
+
if (result.status === 'OK' && result.token) {
|
|
899
|
+
capture(result.token, 'android_install_referrer');
|
|
900
|
+
}
|
|
901
|
+
return result;
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
function scheduleWake(): void {
|
|
905
|
+
if (retryTimer) {
|
|
906
|
+
clearTimeout(retryTimer);
|
|
907
|
+
retryTimer = null;
|
|
908
|
+
}
|
|
909
|
+
if (disposed || options.autoRetry === false) return;
|
|
910
|
+
const candidates = [
|
|
911
|
+
state.pending?.nextRetryAt ?? 0,
|
|
912
|
+
lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0,
|
|
913
|
+
accountRetryAt,
|
|
914
|
+
].filter((value) => value > now());
|
|
915
|
+
if (candidates.length === 0) return;
|
|
916
|
+
const next = Math.min(...candidates);
|
|
917
|
+
retryTimer = setTimeout(() => {
|
|
918
|
+
retryTimer = null;
|
|
919
|
+
void process();
|
|
920
|
+
if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
|
|
921
|
+
void matchFirstOpen(lastFirstOpenContext);
|
|
922
|
+
}
|
|
923
|
+
}, Math.max(0, next - now()));
|
|
924
|
+
const maybeTimer = retryTimer as unknown as { unref?: () => void };
|
|
925
|
+
maybeTimer.unref?.();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (options.subscribeAccountState) {
|
|
929
|
+
try {
|
|
930
|
+
unsubscribeAccount = options.subscribeAccountState(() => { void process(); });
|
|
931
|
+
} catch (error) {
|
|
932
|
+
reportError(error);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
scheduleWake();
|
|
936
|
+
void process();
|
|
937
|
+
|
|
938
|
+
return {
|
|
939
|
+
capture,
|
|
940
|
+
captureUrl: (url) => {
|
|
941
|
+
const token = extractDeferredHandoffToken(url);
|
|
942
|
+
return token ? capture(token, 'direct_token') : false;
|
|
943
|
+
},
|
|
944
|
+
process,
|
|
945
|
+
matchFirstOpen,
|
|
946
|
+
captureAndroidInstallReferrer,
|
|
947
|
+
getState: snapshot,
|
|
948
|
+
subscribe: (listener) => {
|
|
949
|
+
listeners.add(listener);
|
|
950
|
+
return () => listeners.delete(listener);
|
|
951
|
+
},
|
|
952
|
+
dispose: () => {
|
|
953
|
+
if (disposed) return;
|
|
954
|
+
disposed = true;
|
|
955
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
956
|
+
retryTimer = null;
|
|
957
|
+
unsubscribeAccount?.();
|
|
958
|
+
unsubscribeAccount = null;
|
|
959
|
+
listeners.clear();
|
|
960
|
+
},
|
|
961
|
+
reset: () => {
|
|
962
|
+
accountRetryAt = 0;
|
|
963
|
+
accountAttempts = 0;
|
|
964
|
+
state = emptyState();
|
|
965
|
+
persistAndNotify();
|
|
966
|
+
},
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** @deprecated Prefer the product-facing `createPulseLinkClient` name. */
|
|
971
|
+
export const createDeferredLinkClient = createPulseLinkClient;
|
|
972
|
+
|
|
973
|
+
function normalizeBaseUrl(base: string): string {
|
|
974
|
+
return `${base.trim().replace(/\/+$/, '')}/`;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function normalizeAppSlug(value: string | undefined): string | null {
|
|
978
|
+
if (typeof value !== 'string') return null;
|
|
979
|
+
const slug = value.trim().toLowerCase();
|
|
980
|
+
return /^[a-z0-9][a-z0-9_-]{0,79}$/.test(slug) ? slug : null;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function matchUrlOf(resolverBaseUrl: string): string {
|
|
984
|
+
return resolverBaseUrl.replace(/\/t\/d\/$/i, '/t/match');
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function boundedDuration(value: number | undefined, fallback: number, min: number, max: number): number {
|
|
988
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
|
989
|
+
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function isAllowedDeepLink(deepLink: string, prefixes: readonly string[]): boolean {
|
|
993
|
+
const normalized = deepLink.trim().toLowerCase();
|
|
994
|
+
return normalized.length > 0
|
|
995
|
+
&& normalized.length <= 2_048
|
|
996
|
+
&& prefixes.some((prefix) => normalized.startsWith(prefix));
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function clampConfidence(value: unknown, fallback: number): number {
|
|
1000
|
+
const number = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
1001
|
+
return Math.max(0, Math.min(1, number));
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** Non-cryptographic local dedupe key. It is never authorization evidence or sent to analytics. */
|
|
1005
|
+
function stableOutcomeKey(linkId: string, name: DeferredLinkOutcomeName): string {
|
|
1006
|
+
let first = 0x811c9dc5;
|
|
1007
|
+
let second = 0x9e3779b9;
|
|
1008
|
+
for (let index = 0; index < linkId.length; index += 1) {
|
|
1009
|
+
const code = linkId.charCodeAt(index);
|
|
1010
|
+
first = Math.imul(first ^ code, 0x01000193) >>> 0;
|
|
1011
|
+
second = Math.imul(second ^ (code + index), 0x85ebca6b) >>> 0;
|
|
1012
|
+
}
|
|
1013
|
+
return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}|${name}`;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function boundedMetadata(value: unknown): string | null | undefined {
|
|
1017
|
+
if (value === null) return null;
|
|
1018
|
+
if (typeof value !== 'string' && typeof value !== 'number') return undefined;
|
|
1019
|
+
const result = String(value).trim();
|
|
1020
|
+
return result.length > 0 && result.length <= 256 ? result : undefined;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function normalizeResolved<Action extends string>(
|
|
1024
|
+
raw: unknown,
|
|
1025
|
+
fallbackId: string,
|
|
1026
|
+
fallbackBasis: DeferredLinkMatchBasis,
|
|
1027
|
+
allowedActions: ReadonlySet<Action>,
|
|
1028
|
+
now: number,
|
|
1029
|
+
): ResolvedDeferredLink<Action> | null {
|
|
1030
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
1031
|
+
const value = raw as Record<string, unknown>;
|
|
1032
|
+
const action = typeof value.action === 'string' && allowedActions.has(value.action as Action)
|
|
1033
|
+
? value.action as Action
|
|
1034
|
+
: undefined;
|
|
1035
|
+
const deepLink = typeof value.deepLink === 'string' && value.deepLink.length <= 2_048
|
|
1036
|
+
? value.deepLink.trim()
|
|
1037
|
+
: undefined;
|
|
1038
|
+
if (!action && !deepLink) return null;
|
|
1039
|
+
|
|
1040
|
+
const rawBasis = typeof value.matchBasis === 'string'
|
|
1041
|
+
&& MATCH_BASES.has(value.matchBasis as DeferredLinkMatchBasis)
|
|
1042
|
+
? value.matchBasis as DeferredLinkMatchBasis
|
|
1043
|
+
: fallbackBasis;
|
|
1044
|
+
const matchBasis = rawBasis === 'direct_token'
|
|
1045
|
+
&& (fallbackBasis === 'android_install_referrer' || fallbackBasis === 'ios_user_paste')
|
|
1046
|
+
? fallbackBasis
|
|
1047
|
+
: rawBasis;
|
|
1048
|
+
const guaranteedDefault = matchBasis !== 'unique_probabilistic' && matchBasis !== 'unmatched';
|
|
1049
|
+
const matchGuaranteed = matchBasis === 'unique_probabilistic' || matchBasis === 'unmatched'
|
|
1050
|
+
? false
|
|
1051
|
+
: typeof value.matchGuaranteed === 'boolean' ? value.matchGuaranteed : guaranteedDefault;
|
|
1052
|
+
const confidence = clampConfidence(value.confidence, matchGuaranteed ? 1 : 0);
|
|
1053
|
+
const rawId = typeof value.id === 'string' ? value.id : fallbackId;
|
|
1054
|
+
const id = normalizeDeferredHandoffToken(rawId) ?? normalizeDeferredHandoffToken(fallbackId);
|
|
1055
|
+
if (!id) return null;
|
|
1056
|
+
|
|
1057
|
+
const expiresAt = typeof value.expiresAt === 'string' ? value.expiresAt : undefined;
|
|
1058
|
+
if (expiresAt) {
|
|
1059
|
+
const expiry = new Date(expiresAt).getTime();
|
|
1060
|
+
if (!Number.isFinite(expiry) || expiry <= now) return null;
|
|
1061
|
+
}
|
|
1062
|
+
const source = boundedMetadata(value.source);
|
|
1063
|
+
const campaignId = boundedMetadata(value.campaignId);
|
|
1064
|
+
const experimentId = boundedMetadata(value.experimentId);
|
|
1065
|
+
const variantId = boundedMetadata(value.variantId);
|
|
1066
|
+
return {
|
|
1067
|
+
id,
|
|
1068
|
+
...(action ? { action } : {}),
|
|
1069
|
+
...(deepLink ? { deepLink } : {}),
|
|
1070
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
1071
|
+
...(source !== undefined ? { source } : {}),
|
|
1072
|
+
...(campaignId !== undefined ? { campaignId } : {}),
|
|
1073
|
+
...(experimentId !== undefined ? { experimentId } : {}),
|
|
1074
|
+
...(variantId !== undefined ? { variantId } : {}),
|
|
1075
|
+
matchBasis,
|
|
1076
|
+
matchGuaranteed,
|
|
1077
|
+
confidence,
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function isEligibleFirstOpen(
|
|
1082
|
+
context: AnonymousFirstOpenContext,
|
|
1083
|
+
now: number,
|
|
1084
|
+
maxAgeMs: number,
|
|
1085
|
+
): boolean {
|
|
1086
|
+
const appBundleId = context.appBundleId.trim();
|
|
1087
|
+
const locale = normalizeLocale(context.locale);
|
|
1088
|
+
return context.platform === 'ios'
|
|
1089
|
+
&& APP_IDENTITY.test(appBundleId)
|
|
1090
|
+
&& appBundleId.length <= 200
|
|
1091
|
+
&& LOCALE.test(locale)
|
|
1092
|
+
&& locale.length <= 35
|
|
1093
|
+
&& Number.isFinite(context.installedAt)
|
|
1094
|
+
&& context.installedAt > 0
|
|
1095
|
+
&& context.installedAt <= now
|
|
1096
|
+
&& now - context.installedAt <= maxAgeMs;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function normalizeLocale(locale: string): string {
|
|
1100
|
+
return locale.trim().replace(/_/g, '-');
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function makeInstallAttemptId(custom?: () => string): string {
|
|
1104
|
+
try {
|
|
1105
|
+
const candidate = custom?.()
|
|
1106
|
+
?? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto?.randomUUID?.();
|
|
1107
|
+
if (candidate && INSTALL_ATTEMPT_ID.test(candidate)) return candidate.toLowerCase();
|
|
1108
|
+
} catch { /* use the non-identity fallback below */ }
|
|
1109
|
+
const hex = (): string => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0');
|
|
1110
|
+
return `${hex()}${hex()}-${hex()}-4${hex().slice(1)}-${(8 + Math.floor(Math.random() * 4)).toString(16)}${hex().slice(1)}-${hex()}${hex()}${hex()}`;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
async function withTimeout(
|
|
1114
|
+
fetcher: typeof fetch,
|
|
1115
|
+
input: string,
|
|
1116
|
+
init: RequestInit,
|
|
1117
|
+
timeoutMs: number,
|
|
1118
|
+
): Promise<Response> {
|
|
1119
|
+
const controller = new AbortController();
|
|
1120
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1121
|
+
try {
|
|
1122
|
+
return await fetcher(input, { ...init, signal: controller.signal });
|
|
1123
|
+
} finally {
|
|
1124
|
+
clearTimeout(timer);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
async function postResolverOutcome<Action extends string>(
|
|
1129
|
+
fetcher: typeof fetch,
|
|
1130
|
+
resolverBaseUrl: string,
|
|
1131
|
+
link: ResolvedDeferredLink<Action>,
|
|
1132
|
+
name: DeferredLinkOutcomeName,
|
|
1133
|
+
timeoutMs: number,
|
|
1134
|
+
): Promise<void> {
|
|
1135
|
+
await withTimeout(
|
|
1136
|
+
fetcher,
|
|
1137
|
+
`${resolverBaseUrl}${encodeURIComponent(link.id)}/event/${name}`,
|
|
1138
|
+
{
|
|
1139
|
+
method: 'POST',
|
|
1140
|
+
headers: {
|
|
1141
|
+
'Content-Type': 'application/json',
|
|
1142
|
+
},
|
|
1143
|
+
body: JSON.stringify({ matchBasis: link.matchBasis, confidence: link.confidence }),
|
|
1144
|
+
},
|
|
1145
|
+
timeoutMs,
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function readState(storage: ConfigStorage | undefined, key: string): PersistedDeferredLinkState {
|
|
1150
|
+
if (!storage) return emptyState();
|
|
1151
|
+
try {
|
|
1152
|
+
const raw = storage.getString(key);
|
|
1153
|
+
if (!raw || raw.length > MAX_PERSISTED_BYTES) return emptyState();
|
|
1154
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
1155
|
+
if (parsed.version !== 1) return emptyState();
|
|
1156
|
+
const fallback = emptyState();
|
|
1157
|
+
const status = typeof parsed.status === 'string' && STATUSES.has(parsed.status as DeferredLinkStatus)
|
|
1158
|
+
? parsed.status as DeferredLinkStatus
|
|
1159
|
+
: fallback.status;
|
|
1160
|
+
const pendingValue = parsed.pending && typeof parsed.pending === 'object'
|
|
1161
|
+
? parsed.pending as Record<string, unknown>
|
|
1162
|
+
: null;
|
|
1163
|
+
const pendingToken = normalizeDeferredHandoffToken(pendingValue?.token as string | undefined);
|
|
1164
|
+
const pendingBasis = typeof pendingValue?.matchBasis === 'string'
|
|
1165
|
+
&& CAPTURE_BASES.has(pendingValue.matchBasis as DeferredLinkCaptureBasis)
|
|
1166
|
+
? pendingValue.matchBasis as DeferredLinkCaptureBasis
|
|
1167
|
+
: null;
|
|
1168
|
+
const pending = pendingToken && pendingBasis
|
|
1169
|
+
? {
|
|
1170
|
+
token: pendingToken,
|
|
1171
|
+
receivedAt: safeTimestamp(pendingValue?.receivedAt),
|
|
1172
|
+
matchBasis: pendingBasis,
|
|
1173
|
+
attempts: safeInteger(pendingValue?.attempts, 100_000),
|
|
1174
|
+
nextRetryAt: safeTimestamp(pendingValue?.nextRetryAt),
|
|
1175
|
+
}
|
|
1176
|
+
: null;
|
|
1177
|
+
const appliedIds = Array.isArray(parsed.appliedIds)
|
|
1178
|
+
? parsed.appliedIds.filter(isOpaqueDeferredLinkToken).slice(-MAX_APPLIED_IDS)
|
|
1179
|
+
: [];
|
|
1180
|
+
const lastApplied = normalizeDeferredHandoffToken(parsed.lastAppliedId as string | undefined);
|
|
1181
|
+
if (lastApplied && !appliedIds.includes(lastApplied)) appliedIds.push(lastApplied);
|
|
1182
|
+
const firstValue = parsed.firstOpen && typeof parsed.firstOpen === 'object'
|
|
1183
|
+
? parsed.firstOpen as Record<string, unknown>
|
|
1184
|
+
: {};
|
|
1185
|
+
const attemptId = typeof firstValue.installAttemptId === 'string'
|
|
1186
|
+
&& INSTALL_ATTEMPT_ID.test(firstValue.installAttemptId)
|
|
1187
|
+
? firstValue.installAttemptId.toLowerCase()
|
|
1188
|
+
: null;
|
|
1189
|
+
const notifiedOutcomes = Array.isArray(parsed.notifiedOutcomes)
|
|
1190
|
+
? parsed.notifiedOutcomes
|
|
1191
|
+
.filter((value): value is string => typeof value === 'string'
|
|
1192
|
+
&& /^[a-f0-9]{16}\|(app_open_confirmed|deferred_link_resolved|action_applied)$/.test(value))
|
|
1193
|
+
.slice(-MAX_NOTIFIED_OUTCOMES)
|
|
1194
|
+
: [];
|
|
1195
|
+
return {
|
|
1196
|
+
version: 1,
|
|
1197
|
+
status: status === 'resolving' ? (pending ? 'pending' : 'idle') : status,
|
|
1198
|
+
pending,
|
|
1199
|
+
lastAppliedId: lastApplied,
|
|
1200
|
+
appliedIds: [...new Set(appliedIds)].slice(-MAX_APPLIED_IDS),
|
|
1201
|
+
notifiedOutcomes: [...new Set(notifiedOutcomes)].slice(-MAX_NOTIFIED_OUTCOMES),
|
|
1202
|
+
firstOpen: {
|
|
1203
|
+
installAttemptId: attemptId,
|
|
1204
|
+
completed: firstValue.completed === true,
|
|
1205
|
+
attemptedAt: safeTimestamp(firstValue.attemptedAt),
|
|
1206
|
+
attempts: safeInteger(firstValue.attempts, 100_000),
|
|
1207
|
+
nextRetryAt: safeTimestamp(firstValue.nextRetryAt),
|
|
1208
|
+
},
|
|
1209
|
+
};
|
|
1210
|
+
} catch {
|
|
1211
|
+
return emptyState();
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function writeState(
|
|
1216
|
+
storage: ConfigStorage | undefined,
|
|
1217
|
+
key: string,
|
|
1218
|
+
state: PersistedDeferredLinkState,
|
|
1219
|
+
): void {
|
|
1220
|
+
if (!storage) return;
|
|
1221
|
+
try { void storage.set(key, JSON.stringify(state)); } catch { /* in-memory operation continues */ }
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function safeTimestamp(value: unknown): number {
|
|
1225
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function safeInteger(value: unknown, max: number): number {
|
|
1229
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
1230
|
+
? Math.max(0, Math.min(max, Math.trunc(value)))
|
|
1231
|
+
: 0;
|
|
1232
|
+
}
|