pulse-updates 1.3.7 → 1.3.9
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 +35 -2
- package/android/src/main/java/app/pulse/updates/PulseController.kt +29 -0
- package/ios/PulseUpdates/PulseController.swift +10 -0
- package/lib/commonjs/config.js.map +1 -1
- package/lib/commonjs/init.js +13 -1
- package/lib/commonjs/init.js.map +1 -1
- package/lib/commonjs/links.js +698 -73
- package/lib/commonjs/links.js.map +1 -1
- package/lib/commonjs/track.js +198 -75
- package/lib/commonjs/track.js.map +1 -1
- package/lib/module/config.js.map +1 -1
- package/lib/module/init.js +14 -2
- package/lib/module/init.js.map +1 -1
- package/lib/module/links.js +697 -73
- package/lib/module/links.js.map +1 -1
- package/lib/module/track.js +197 -75
- package/lib/module/track.js.map +1 -1
- package/lib/typescript/config.d.ts +6 -0
- package/lib/typescript/config.d.ts.map +1 -1
- package/lib/typescript/init.d.ts +1 -0
- package/lib/typescript/init.d.ts.map +1 -1
- package/lib/typescript/links.d.ts +63 -0
- package/lib/typescript/links.d.ts.map +1 -1
- package/lib/typescript/track.d.ts +17 -6
- package/lib/typescript/track.d.ts.map +1 -1
- package/package.json +4 -2
- package/scripts/publish.mjs +227 -16
- package/src/config.ts +6 -0
- package/src/init.ts +16 -1
- package/src/links.ts +983 -51
- package/src/track.ts +283 -81
package/src/links.ts
CHANGED
|
@@ -122,6 +122,14 @@ export interface AnonymousFirstOpenContext {
|
|
|
122
122
|
/** JavaScript Date#getTimezoneOffset semantics, bounded to real-world UTC offsets. */
|
|
123
123
|
timezoneOffsetMinutes?: number;
|
|
124
124
|
isEmulator?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* The address the person is signed in with, when the host app has one. An install that arrives
|
|
127
|
+
* with an account does not need to be guessed at: the resolver binds it to the message sent to
|
|
128
|
+
* that address and skips probabilistic matching entirely. Omitted for signed-out first opens,
|
|
129
|
+
* and never read from the clipboard or any other ambient source — the app passes what it
|
|
130
|
+
* already knows, or nothing.
|
|
131
|
+
*/
|
|
132
|
+
accountEmail?: string;
|
|
125
133
|
}
|
|
126
134
|
|
|
127
135
|
export type AnonymousFirstOpenResult =
|
|
@@ -134,6 +142,51 @@ export type AnonymousFirstOpenResult =
|
|
|
134
142
|
| 'retry_scheduled'
|
|
135
143
|
| 'terminal_error';
|
|
136
144
|
|
|
145
|
+
export type AnonymousFirstOpenTerminalStatus = 'FOUND' | 'NOT_FOUND' | 'FAILURE';
|
|
146
|
+
|
|
147
|
+
/** Explicit receiver decision for one terminal-delivery attempt. */
|
|
148
|
+
export type AnonymousFirstOpenTerminalDisposition = 'accepted' | 'retry' | 'drop';
|
|
149
|
+
|
|
150
|
+
export type AnonymousFirstOpenTerminalReason =
|
|
151
|
+
| 'matched'
|
|
152
|
+
| 'unmatched'
|
|
153
|
+
| 'ambiguous'
|
|
154
|
+
| 'holdout'
|
|
155
|
+
| 'low_confidence'
|
|
156
|
+
| 'shadow_would_route'
|
|
157
|
+
| 'shadow_attributed'
|
|
158
|
+
| 'analytics_attributed'
|
|
159
|
+
| 'target_revoked'
|
|
160
|
+
| 'disabled'
|
|
161
|
+
| 'expired_replay'
|
|
162
|
+
| 'invalid_request'
|
|
163
|
+
| 'invalid_response'
|
|
164
|
+
| 'policy_rejected';
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Sanitized terminal result of the anonymous first-open rail. It deliberately carries no
|
|
168
|
+
* install-attempt id, token, device signal or raw server error. A 204 is always `no_route` and
|
|
169
|
+
* can never make the client navigate, including when Encore reports a shadow observation.
|
|
170
|
+
*/
|
|
171
|
+
export interface AnonymousFirstOpenTerminalOutcome {
|
|
172
|
+
/** Stable UUIDv4 for receiver-side deduplication of at-least-once delivery. */
|
|
173
|
+
eventId: string;
|
|
174
|
+
status: AnonymousFirstOpenTerminalStatus;
|
|
175
|
+
rail: 'fast_route' | 'no_route';
|
|
176
|
+
routed: boolean;
|
|
177
|
+
retryable: false;
|
|
178
|
+
reason: AnonymousFirstOpenTerminalReason;
|
|
179
|
+
occurredAt: string;
|
|
180
|
+
/** Present only for FOUND and copied from the already-normalized resolver result. */
|
|
181
|
+
matchBasis?: DeferredLinkMatchBasis;
|
|
182
|
+
/** Present only for FOUND, finite and clamped to [0, 1]. */
|
|
183
|
+
confidence?: number;
|
|
184
|
+
/** Optional FOUND-only campaign dimensions; strings are trimmed and bounded to 128 chars. */
|
|
185
|
+
campaignId?: string | null;
|
|
186
|
+
experimentId?: string | null;
|
|
187
|
+
variantId?: string | null;
|
|
188
|
+
}
|
|
189
|
+
|
|
137
190
|
export type AndroidInstallReferrerStatus =
|
|
138
191
|
| 'OK'
|
|
139
192
|
| 'NO_TOKEN'
|
|
@@ -163,7 +216,13 @@ export interface DeferredLinkClientOptions<Action extends string = DeferredLinkA
|
|
|
163
216
|
allowedActions?: readonly Action[];
|
|
164
217
|
/** Actions that require both a deterministic match and a stable signed-in account. */
|
|
165
218
|
sensitiveActions?: readonly Action[];
|
|
219
|
+
/**
|
|
220
|
+
* Routing callbacks are irreversible commit boundaries: return `false` when no navigation
|
|
221
|
+
* happened. If a newer accepted capture arrives while a successful callback is pending, Pulse
|
|
222
|
+
* persists it but will not auto-route a second journey in the same client lifecycle.
|
|
223
|
+
*/
|
|
166
224
|
onDeepLink: (deepLink: string, link: ResolvedDeferredLink<Action>) => boolean | void | Promise<boolean | void>;
|
|
225
|
+
/** Same commit-boundary contract as `onDeepLink`. */
|
|
167
226
|
onAction?: (link: ResolvedDeferredLink<Action>) => boolean | void | Promise<boolean | void>;
|
|
168
227
|
accountBridge?: DeferredLinkAccountBridge<Action>;
|
|
169
228
|
isAccountReady?: () => boolean;
|
|
@@ -179,11 +238,31 @@ export interface DeferredLinkClientOptions<Action extends string = DeferredLinkA
|
|
|
179
238
|
requestTimeoutMs?: number;
|
|
180
239
|
retryBaseMs?: number;
|
|
181
240
|
retryMaxMs?: number;
|
|
241
|
+
/** Timeout for one terminal-result acknowledgement attempt. Defaults to requestTimeoutMs. */
|
|
242
|
+
terminalDeliveryTimeoutMs?: number;
|
|
182
243
|
tokenMaxAgeMs?: number;
|
|
183
244
|
recentInstallMaxAgeMs?: number;
|
|
184
245
|
autoRetry?: boolean;
|
|
185
246
|
reportResolverOutcomes?: boolean;
|
|
186
247
|
onOutcome?: (event: DeferredLinkOutcomeEvent) => void | Promise<void>;
|
|
248
|
+
/**
|
|
249
|
+
* Synchronous privacy gate evaluated before a terminal record or eventId is created. A false
|
|
250
|
+
* result or throw completes first-open matching without retaining terminal metadata.
|
|
251
|
+
*/
|
|
252
|
+
shouldQueueFirstOpenResult?: () => boolean;
|
|
253
|
+
/**
|
|
254
|
+
* Durable at-least-once terminal delivery. Return `accepted` (or legacy `true`) only after the
|
|
255
|
+
* stable eventId is durably accepted, `retry`/false to replay it, or `drop` to deliberately and
|
|
256
|
+
* durably discard it (for example after consent revocation). Throw, rejection and timeout retry
|
|
257
|
+
* the same eventId. `drop` is sticky: failed local deletion retries never re-enter the receiver.
|
|
258
|
+
* Receivers must deduplicate because a crash after acceptance can replay it. When this handler
|
|
259
|
+
* is absent no terminal record is created or retained.
|
|
260
|
+
*/
|
|
261
|
+
onFirstOpenResult?: (
|
|
262
|
+
outcome: AnonymousFirstOpenTerminalOutcome,
|
|
263
|
+
) => AnonymousFirstOpenTerminalDisposition
|
|
264
|
+
| boolean
|
|
265
|
+
| Promise<AnonymousFirstOpenTerminalDisposition | boolean>;
|
|
187
266
|
onStateChange?: (state: DeferredLinkState) => void;
|
|
188
267
|
onError?: (error: unknown) => void;
|
|
189
268
|
now?: () => number;
|
|
@@ -255,6 +334,29 @@ const DEFAULT_ACTIONS = [
|
|
|
255
334
|
'refresh_entitlement',
|
|
256
335
|
] as const;
|
|
257
336
|
const DEFAULT_SENSITIVE_ACTIONS = ['manage_subscription', 'refresh_entitlement'] as const;
|
|
337
|
+
const PROBABILISTIC_ACTIONS = new Set<string>(['open_home', 'open_premium']);
|
|
338
|
+
const CAPTURE_PRIORITY: Readonly<Record<DeferredLinkCaptureBasis, number>> = {
|
|
339
|
+
direct_token: 3,
|
|
340
|
+
ios_user_paste: 2,
|
|
341
|
+
android_install_referrer: 1,
|
|
342
|
+
};
|
|
343
|
+
const NO_ROUTE_REASONS = new Set<AnonymousFirstOpenTerminalReason>([
|
|
344
|
+
'unmatched',
|
|
345
|
+
'ambiguous',
|
|
346
|
+
'holdout',
|
|
347
|
+
'low_confidence',
|
|
348
|
+
'shadow_would_route',
|
|
349
|
+
'shadow_attributed',
|
|
350
|
+
'analytics_attributed',
|
|
351
|
+
'target_revoked',
|
|
352
|
+
'disabled',
|
|
353
|
+
'expired_replay',
|
|
354
|
+
]);
|
|
355
|
+
const FAILURE_REASONS = new Set<AnonymousFirstOpenTerminalReason>([
|
|
356
|
+
'invalid_request',
|
|
357
|
+
'invalid_response',
|
|
358
|
+
'policy_rejected',
|
|
359
|
+
]);
|
|
258
360
|
const DEFAULT_STORAGE_KEY = 'pulse.links.v1';
|
|
259
361
|
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
260
362
|
const DEFAULT_RETRY_BASE_MS = 15_000;
|
|
@@ -265,6 +367,7 @@ const MAX_APPLIED_IDS = 32;
|
|
|
265
367
|
const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
|
|
266
368
|
const MAX_RESOLVER_OUTCOMES = MAX_NOTIFIED_OUTCOMES;
|
|
267
369
|
const MAX_PERSISTED_BYTES = 131_072;
|
|
370
|
+
const ASYNCHRONOUS_STORAGE_ADAPTERS = new WeakSet<object>();
|
|
268
371
|
const VERSION_SIGNAL = /^[A-Za-z0-9][A-Za-z0-9._+()-]*$/;
|
|
269
372
|
const DEVICE_MODEL_CODE = /^[A-Za-z0-9][A-Za-z0-9._,+-]*$/;
|
|
270
373
|
const DISTRIBUTION_SIGNAL = /^[a-z0-9][a-z0-9._-]*$/;
|
|
@@ -293,6 +396,24 @@ interface QueuedResolverOutcome {
|
|
|
293
396
|
nextRetryAt: number;
|
|
294
397
|
}
|
|
295
398
|
|
|
399
|
+
interface QueuedFirstOpenTerminalDelivery extends AnonymousFirstOpenTerminalOutcome {
|
|
400
|
+
disposition?: never;
|
|
401
|
+
attempts: number;
|
|
402
|
+
nextRetryAt: number;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Minimal sticky privacy tombstone. It carries no terminal outcome or attribution metadata. */
|
|
406
|
+
interface DroppedFirstOpenTerminalTombstone {
|
|
407
|
+
eventId: string;
|
|
408
|
+
disposition: 'drop';
|
|
409
|
+
attempts: number;
|
|
410
|
+
nextRetryAt: number;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
type PersistedFirstOpenTerminalDelivery =
|
|
414
|
+
| QueuedFirstOpenTerminalDelivery
|
|
415
|
+
| DroppedFirstOpenTerminalTombstone;
|
|
416
|
+
|
|
296
417
|
interface PersistedDeferredLinkState {
|
|
297
418
|
version: 1;
|
|
298
419
|
status: DeferredLinkStatus;
|
|
@@ -303,6 +424,8 @@ interface PersistedDeferredLinkState {
|
|
|
303
424
|
notifiedOutcomes: string[];
|
|
304
425
|
/** Durable Encore delivery queue. Exposure tokens stay local and are used only in URL paths. */
|
|
305
426
|
outcomeQueue: QueuedResolverOutcome[];
|
|
427
|
+
/** One-record at-least-once application delivery outbox, independent of matcher completion. */
|
|
428
|
+
terminalDelivery: PersistedFirstOpenTerminalDelivery | null;
|
|
306
429
|
firstOpen: DeferredLinkFirstOpenState;
|
|
307
430
|
}
|
|
308
431
|
|
|
@@ -319,6 +442,7 @@ const emptyState = (): PersistedDeferredLinkState => ({
|
|
|
319
442
|
appliedIds: [],
|
|
320
443
|
notifiedOutcomes: [],
|
|
321
444
|
outcomeQueue: [],
|
|
445
|
+
terminalDelivery: null,
|
|
322
446
|
firstOpen: {
|
|
323
447
|
installAttemptId: null,
|
|
324
448
|
completed: false,
|
|
@@ -430,6 +554,12 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
430
554
|
}
|
|
431
555
|
const storageKey = explicitStorageKey || (appSlug ? `pulse.${appSlug}.links.v1` : DEFAULT_STORAGE_KEY);
|
|
432
556
|
const requestTimeoutMs = boundedDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 500, 60_000);
|
|
557
|
+
const terminalDeliveryTimeoutMs = boundedDuration(
|
|
558
|
+
options.terminalDeliveryTimeoutMs,
|
|
559
|
+
requestTimeoutMs,
|
|
560
|
+
100,
|
|
561
|
+
60_000,
|
|
562
|
+
);
|
|
433
563
|
const retryBaseMs = boundedDuration(options.retryBaseMs, DEFAULT_RETRY_BASE_MS, 100, 60 * 60 * 1_000);
|
|
434
564
|
const retryMaxMs = boundedDuration(options.retryMaxMs, DEFAULT_RETRY_MAX_MS, retryBaseMs, 24 * 60 * 60 * 1_000);
|
|
435
565
|
const tokenMaxAgeMs = boundedDuration(options.tokenMaxAgeMs, DEFAULT_TOKEN_MAX_AGE_MS, 60_000, 365 * 24 * 60 * 60 * 1_000);
|
|
@@ -443,11 +573,26 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
443
573
|
let state = readState(options.storage, storageKey);
|
|
444
574
|
let disposed = false;
|
|
445
575
|
let processing: Promise<void> | null = null;
|
|
576
|
+
let firstOpenMatching: Promise<AnonymousFirstOpenResult> | null = null;
|
|
446
577
|
let processRequested = false;
|
|
447
578
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
448
579
|
let outcomeSending: Promise<number> | null = null;
|
|
580
|
+
let terminalDeliverySending: Promise<boolean> | null = null;
|
|
581
|
+
let terminalDeliveryGeneration = 0;
|
|
449
582
|
let accountRetryAt = 0;
|
|
450
583
|
let accountAttempts = 0;
|
|
584
|
+
let deterministicEpoch = 0;
|
|
585
|
+
// Invalidates every host callback that crossed an await when this client is reset/disposed.
|
|
586
|
+
// This is deliberately separate from deterministicEpoch: captures during a committed callback
|
|
587
|
+
// have intentional last-touch semantics, whereas a lifecycle boundary must forbid every stale
|
|
588
|
+
// state write, retry and outcome from the old instance.
|
|
589
|
+
let lifecycleGeneration = 0;
|
|
590
|
+
// Host navigation is irreversible once any routing callback has started: it may perform its
|
|
591
|
+
// side effect synchronously before returning a Promise. Serialize captures against that commit
|
|
592
|
+
// boundary and keep the newer accepted token durable for the next client lifecycle instead of
|
|
593
|
+
// automatically opening a second journey behind the first one.
|
|
594
|
+
let routingApplicationInFlight = false;
|
|
595
|
+
let deterministicTokenDeferredAfterCommittedJourney: string | null = null;
|
|
451
596
|
let lastFirstOpenContext: AnonymousFirstOpenContext | null = null;
|
|
452
597
|
let unsubscribeAccount: (() => void) | null = null;
|
|
453
598
|
const listeners = new Set<(value: DeferredLinkState) => void>();
|
|
@@ -456,6 +601,24 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
456
601
|
try { options.onError?.(error); } catch { /* diagnostic hooks never break link handling */ }
|
|
457
602
|
};
|
|
458
603
|
|
|
604
|
+
// A build without a receiver has not opted into retaining this analytics envelope. First replace
|
|
605
|
+
// an old full record with the same minimal sticky tombstone used by an explicit drop. Deletion
|
|
606
|
+
// may fail, but a later app version can then only retry local deletion, never resurrect delivery.
|
|
607
|
+
if (!options.onFirstOpenResult
|
|
608
|
+
&& state.terminalDelivery
|
|
609
|
+
&& state.terminalDelivery.disposition !== 'drop') {
|
|
610
|
+
const tombstone: DroppedFirstOpenTerminalTombstone = {
|
|
611
|
+
eventId: state.terminalDelivery.eventId,
|
|
612
|
+
disposition: 'drop',
|
|
613
|
+
attempts: state.terminalDelivery.attempts,
|
|
614
|
+
nextRetryAt: 0,
|
|
615
|
+
};
|
|
616
|
+
state = { ...state, terminalDelivery: tombstone };
|
|
617
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
618
|
+
reportError(new Error('Pulse Links: stale terminal delivery tombstone was not persisted'));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
459
622
|
const snapshot = (): DeferredLinkState => ({
|
|
460
623
|
status: state.status,
|
|
461
624
|
pending: state.pending ? { ...state.pending } : null,
|
|
@@ -464,8 +627,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
464
627
|
firstOpen: { ...state.firstOpen },
|
|
465
628
|
});
|
|
466
629
|
|
|
467
|
-
const
|
|
468
|
-
writeState(options.storage, storageKey, state);
|
|
630
|
+
const notifyState = (): void => {
|
|
469
631
|
const value = snapshot();
|
|
470
632
|
for (const listener of listeners) {
|
|
471
633
|
try { listener(value); } catch (error) { reportError(error); }
|
|
@@ -474,6 +636,12 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
474
636
|
scheduleWake();
|
|
475
637
|
};
|
|
476
638
|
|
|
639
|
+
const persistAndNotify = (): boolean => {
|
|
640
|
+
const persisted = writeState(options.storage, storageKey, state);
|
|
641
|
+
notifyState();
|
|
642
|
+
return persisted;
|
|
643
|
+
};
|
|
644
|
+
|
|
477
645
|
const patchState = (patch: Partial<PersistedDeferredLinkState>): void => {
|
|
478
646
|
state = { ...state, ...patch };
|
|
479
647
|
persistAndNotify();
|
|
@@ -484,11 +652,257 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
484
652
|
persistAndNotify();
|
|
485
653
|
};
|
|
486
654
|
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
655
|
+
const terminalDeliveryOutcome = (
|
|
656
|
+
queued: QueuedFirstOpenTerminalDelivery,
|
|
657
|
+
): AnonymousFirstOpenTerminalOutcome => ({
|
|
658
|
+
eventId: queued.eventId,
|
|
659
|
+
status: queued.status,
|
|
660
|
+
rail: queued.rail,
|
|
661
|
+
routed: queued.routed,
|
|
662
|
+
retryable: false,
|
|
663
|
+
reason: queued.reason,
|
|
664
|
+
occurredAt: queued.occurredAt,
|
|
665
|
+
...(queued.matchBasis !== undefined ? { matchBasis: queued.matchBasis } : {}),
|
|
666
|
+
...(queued.confidence !== undefined ? { confidence: queued.confidence } : {}),
|
|
667
|
+
...(queued.campaignId !== undefined ? { campaignId: queued.campaignId } : {}),
|
|
668
|
+
...(queued.experimentId !== undefined ? { experimentId: queued.experimentId } : {}),
|
|
669
|
+
...(queued.variantId !== undefined ? { variantId: queued.variantId } : {}),
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
const scheduleTerminalDeliveryRetry = (
|
|
673
|
+
queued: PersistedFirstOpenTerminalDelivery,
|
|
674
|
+
): void => {
|
|
675
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return;
|
|
676
|
+
const attempts = Math.min(queued.attempts + 1, 100_000);
|
|
677
|
+
state = {
|
|
678
|
+
...state,
|
|
679
|
+
terminalDelivery: {
|
|
680
|
+
...queued,
|
|
681
|
+
attempts,
|
|
682
|
+
nextRetryAt: now() + resolverOutcomeRetryDelay(
|
|
683
|
+
queued.eventId,
|
|
684
|
+
attempts,
|
|
685
|
+
retryBaseMs,
|
|
686
|
+
retryMaxMs,
|
|
687
|
+
),
|
|
688
|
+
},
|
|
689
|
+
};
|
|
690
|
+
persistAndNotify();
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
const acknowledgeTerminalDelivery = (
|
|
694
|
+
queued: PersistedFirstOpenTerminalDelivery,
|
|
695
|
+
): boolean => {
|
|
696
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
697
|
+
const acknowledgedState: PersistedDeferredLinkState = {
|
|
698
|
+
...state,
|
|
699
|
+
terminalDelivery: null,
|
|
700
|
+
};
|
|
701
|
+
// Clearing the record is itself transactional. If this write fails after the receiver
|
|
702
|
+
// accepted the event, retain and replay the same eventId: that is why the contract is
|
|
703
|
+
// at-least-once and why receivers must deduplicate.
|
|
704
|
+
if (!writeState(options.storage, storageKey, acknowledgedState, false)) {
|
|
705
|
+
reportError(new Error('Pulse Links: terminal delivery acknowledgement was not persisted'));
|
|
706
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
state = acknowledgedState;
|
|
710
|
+
notifyState();
|
|
711
|
+
return true;
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const flushTerminalDelivery = (): Promise<boolean> => {
|
|
715
|
+
if (disposed) return Promise.resolve(false);
|
|
716
|
+
if (terminalDeliverySending) return terminalDeliverySending;
|
|
717
|
+
const queued = state.terminalDelivery;
|
|
718
|
+
if (!queued || queued.nextRetryAt > now()) return Promise.resolve(false);
|
|
719
|
+
if (queued.disposition === 'drop') {
|
|
720
|
+
return Promise.resolve(acknowledgeTerminalDelivery(queued));
|
|
721
|
+
}
|
|
722
|
+
if (!options.onFirstOpenResult) return Promise.resolve(false);
|
|
723
|
+
const generation = terminalDeliveryGeneration;
|
|
724
|
+
|
|
725
|
+
const run = async (): Promise<boolean> => {
|
|
726
|
+
// Never call the receiver before the complete record is durable. With no storage adapter,
|
|
727
|
+
// this is an explicitly memory-only degradation rather than a cross-restart guarantee.
|
|
728
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
729
|
+
reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
|
|
730
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
let rawDisposition:
|
|
735
|
+
| AnonymousFirstOpenTerminalDisposition
|
|
736
|
+
| boolean
|
|
737
|
+
| Promise<AnonymousFirstOpenTerminalDisposition | boolean>;
|
|
738
|
+
try {
|
|
739
|
+
rawDisposition = options.onFirstOpenResult!(terminalDeliveryOutcome(queued));
|
|
740
|
+
} catch (error) {
|
|
741
|
+
reportError(error);
|
|
742
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
let disposition: AnonymousFirstOpenTerminalDisposition;
|
|
747
|
+
if (typeof rawDisposition === 'boolean' || typeof rawDisposition === 'string') {
|
|
748
|
+
disposition = normalizeTerminalDeliveryDisposition(rawDisposition);
|
|
749
|
+
} else {
|
|
750
|
+
try {
|
|
751
|
+
const resolved = await withTerminalDeliveryTimeout(
|
|
752
|
+
Promise.resolve(rawDisposition),
|
|
753
|
+
terminalDeliveryTimeoutMs,
|
|
754
|
+
);
|
|
755
|
+
disposition = normalizeTerminalDeliveryDisposition(resolved);
|
|
756
|
+
} catch (error) {
|
|
757
|
+
if (!disposed && terminalDeliveryGeneration === generation) reportError(error);
|
|
758
|
+
if (!disposed && terminalDeliveryGeneration === generation) {
|
|
759
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
760
|
+
}
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (disposed
|
|
766
|
+
|| terminalDeliveryGeneration !== generation
|
|
767
|
+
|| state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
768
|
+
if (disposition === 'accepted') return acknowledgeTerminalDelivery(queued);
|
|
769
|
+
if (disposition === 'drop') return dropTerminalDelivery(queued);
|
|
770
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
771
|
+
return false;
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
let owned: Promise<boolean>;
|
|
775
|
+
owned = run().finally(() => {
|
|
776
|
+
if (terminalDeliverySending === owned) terminalDeliverySending = null;
|
|
777
|
+
const pending = state.terminalDelivery;
|
|
778
|
+
if (!disposed && options.onFirstOpenResult && pending && pending.nextRetryAt <= now()) {
|
|
779
|
+
void flushTerminalDelivery();
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
terminalDeliverySending = owned;
|
|
783
|
+
return owned;
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
const dropTerminalDelivery = (
|
|
787
|
+
queued: QueuedFirstOpenTerminalDelivery,
|
|
788
|
+
): boolean => {
|
|
789
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
790
|
+
const tombstone: DroppedFirstOpenTerminalTombstone = {
|
|
791
|
+
eventId: queued.eventId,
|
|
792
|
+
disposition: 'drop',
|
|
793
|
+
attempts: queued.attempts,
|
|
794
|
+
nextRetryAt: 0,
|
|
795
|
+
};
|
|
796
|
+
state = { ...state, terminalDelivery: tombstone };
|
|
797
|
+
// Persist the sticky disposition before trying to delete it. A failed local deletion can then
|
|
798
|
+
// retry only deletion after restart; it must never call the receiver or resurrect collection.
|
|
799
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
800
|
+
reportError(new Error('Pulse Links: terminal drop tombstone was not persisted'));
|
|
801
|
+
scheduleTerminalDeliveryRetry(tombstone);
|
|
490
802
|
return false;
|
|
491
803
|
}
|
|
804
|
+
notifyState();
|
|
805
|
+
return acknowledgeTerminalDelivery(tombstone);
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
const completeFirstOpen = (
|
|
809
|
+
status: DeferredLinkStatus,
|
|
810
|
+
outcome: Omit<AnonymousFirstOpenTerminalOutcome, 'eventId' | 'occurredAt' | 'retryable'>,
|
|
811
|
+
): void => {
|
|
812
|
+
const completionLifecycleGeneration = lifecycleGeneration;
|
|
813
|
+
const completionDeterministicEpoch = deterministicEpoch;
|
|
814
|
+
const completionIsCurrent = (): boolean => !disposed
|
|
815
|
+
&& lifecycleGeneration === completionLifecycleGeneration
|
|
816
|
+
&& deterministicEpoch === completionDeterministicEpoch;
|
|
817
|
+
let shouldQueue = Boolean(options.onFirstOpenResult);
|
|
818
|
+
if (shouldQueue && options.shouldQueueFirstOpenResult) {
|
|
819
|
+
try {
|
|
820
|
+
shouldQueue = options.shouldQueueFirstOpenResult() === true;
|
|
821
|
+
} catch (error) {
|
|
822
|
+
shouldQueue = false;
|
|
823
|
+
reportError(error);
|
|
824
|
+
}
|
|
825
|
+
// Privacy/diagnostic hooks are host code and may synchronously reset, dispose, or capture a
|
|
826
|
+
// deterministic destination. Never resurrect the terminal first-open state they invalidated.
|
|
827
|
+
if (!completionIsCurrent()) return;
|
|
828
|
+
}
|
|
829
|
+
if (!shouldQueue) {
|
|
830
|
+
state = {
|
|
831
|
+
...state,
|
|
832
|
+
status,
|
|
833
|
+
terminalDelivery: null,
|
|
834
|
+
firstOpen: {
|
|
835
|
+
...state.firstOpen,
|
|
836
|
+
completed: true,
|
|
837
|
+
attempts: 0,
|
|
838
|
+
nextRetryAt: 0,
|
|
839
|
+
},
|
|
840
|
+
};
|
|
841
|
+
persistAndNotify();
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const existingEventIds = new Set<string>([
|
|
846
|
+
...state.outcomeQueue.map((queued) => queued.eventId),
|
|
847
|
+
...(state.firstOpen.installAttemptId ? [state.firstOpen.installAttemptId] : []),
|
|
848
|
+
...(state.terminalDelivery ? [state.terminalDelivery.eventId] : []),
|
|
849
|
+
]);
|
|
850
|
+
const delivery = state.terminalDelivery ?? {
|
|
851
|
+
...outcome,
|
|
852
|
+
eventId: makeOutcomeEventId(options.randomUUID, existingEventIds),
|
|
853
|
+
retryable: false as const,
|
|
854
|
+
occurredAt: new Date(now()).toISOString(),
|
|
855
|
+
attempts: 0,
|
|
856
|
+
nextRetryAt: 0,
|
|
857
|
+
};
|
|
858
|
+
state = {
|
|
859
|
+
...state,
|
|
860
|
+
status,
|
|
861
|
+
terminalDelivery: delivery,
|
|
862
|
+
firstOpen: {
|
|
863
|
+
...state.firstOpen,
|
|
864
|
+
completed: true,
|
|
865
|
+
attempts: 0,
|
|
866
|
+
nextRetryAt: 0,
|
|
867
|
+
},
|
|
868
|
+
};
|
|
869
|
+
if (!persistAndNotify()) {
|
|
870
|
+
reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
|
|
871
|
+
}
|
|
872
|
+
// Delivery is deliberately detached from routing/matcher completion.
|
|
873
|
+
void flushTerminalDelivery();
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const blockFirstOpenForDeterministic = (): boolean => {
|
|
877
|
+
const blockingLifecycleGeneration = lifecycleGeneration;
|
|
878
|
+
deterministicEpoch += 1;
|
|
879
|
+
const blockingDeterministicEpoch = deterministicEpoch;
|
|
880
|
+
if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return true;
|
|
881
|
+
state = {
|
|
882
|
+
...state,
|
|
883
|
+
firstOpen: { ...state.firstOpen, completed: true, nextRetryAt: 0 },
|
|
884
|
+
};
|
|
885
|
+
persistAndNotify();
|
|
886
|
+
return !disposed
|
|
887
|
+
&& lifecycleGeneration === blockingLifecycleGeneration
|
|
888
|
+
&& deterministicEpoch === blockingDeterministicEpoch;
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
const readAccountReadiness = (): { ready: boolean; current: boolean } => {
|
|
892
|
+
const readinessLifecycleGeneration = lifecycleGeneration;
|
|
893
|
+
const readinessDeterministicEpoch = deterministicEpoch;
|
|
894
|
+
let ready = false;
|
|
895
|
+
try { ready = options.isAccountReady?.() ?? false; } catch (error) {
|
|
896
|
+
reportError(error);
|
|
897
|
+
}
|
|
898
|
+
return {
|
|
899
|
+
ready,
|
|
900
|
+
// Readiness is host code, not a pure getter. A reset/dispose/capture inside it invalidates
|
|
901
|
+
// the caller's snapshot and must be observed before any waiting/retry/application write.
|
|
902
|
+
current: !disposed
|
|
903
|
+
&& lifecycleGeneration === readinessLifecycleGeneration
|
|
904
|
+
&& deterministicEpoch === readinessDeterministicEpoch,
|
|
905
|
+
};
|
|
492
906
|
};
|
|
493
907
|
|
|
494
908
|
const schedulePendingRetry = (): void => {
|
|
@@ -723,7 +1137,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
723
1137
|
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
724
1138
|
return false;
|
|
725
1139
|
}
|
|
726
|
-
|
|
1140
|
+
const accountReadiness = readAccountReadiness();
|
|
1141
|
+
if (!accountReadiness.current) return false;
|
|
1142
|
+
if (!accountReadiness.ready) {
|
|
727
1143
|
patchState({ status: 'waiting_for_account' });
|
|
728
1144
|
return false;
|
|
729
1145
|
}
|
|
@@ -740,9 +1156,36 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
740
1156
|
return false;
|
|
741
1157
|
}
|
|
742
1158
|
|
|
1159
|
+
let applied: boolean | void = false;
|
|
1160
|
+
let applicationFailed = false;
|
|
1161
|
+
let applicationError: unknown;
|
|
1162
|
+
const applicationEpoch = deterministicEpoch;
|
|
1163
|
+
const applicationLifecycleGeneration = lifecycleGeneration;
|
|
1164
|
+
const lifecycleIsCurrent = (): boolean => !disposed
|
|
1165
|
+
&& lifecycleGeneration === applicationLifecycleGeneration;
|
|
1166
|
+
const hasNewerPendingIntent = (): boolean => deterministicEpoch !== applicationEpoch
|
|
1167
|
+
&& state.pending !== null
|
|
1168
|
+
// A provenance upgrade or rejected lower-priority capture for the token already being
|
|
1169
|
+
// applied does not represent another destination and must not leave that token pending.
|
|
1170
|
+
&& (sourceToken === null || state.pending.token !== sourceToken);
|
|
1171
|
+
routingApplicationInFlight = true;
|
|
743
1172
|
emitOutcome(link, 'app_open_confirmed');
|
|
1173
|
+
if (!lifecycleIsCurrent()) {
|
|
1174
|
+
routingApplicationInFlight = false;
|
|
1175
|
+
return false;
|
|
1176
|
+
}
|
|
744
1177
|
emitOutcome(link, 'deferred_link_resolved');
|
|
745
|
-
|
|
1178
|
+
if (!lifecycleIsCurrent()) {
|
|
1179
|
+
routingApplicationInFlight = false;
|
|
1180
|
+
return false;
|
|
1181
|
+
}
|
|
1182
|
+
// `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
|
|
1183
|
+
// a newer accepted destination, it still arrived before the routing callback and must win.
|
|
1184
|
+
if (hasNewerPendingIntent()) {
|
|
1185
|
+
routingApplicationInFlight = false;
|
|
1186
|
+
void process();
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
746
1189
|
try {
|
|
747
1190
|
if (link.deepLink) {
|
|
748
1191
|
applied = await options.onDeepLink(link.deepLink, link);
|
|
@@ -753,24 +1196,60 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
753
1196
|
return false;
|
|
754
1197
|
}
|
|
755
1198
|
} catch (error) {
|
|
756
|
-
|
|
1199
|
+
applicationFailed = true;
|
|
1200
|
+
applicationError = error;
|
|
1201
|
+
} finally {
|
|
1202
|
+
routingApplicationInFlight = false;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// The callback may resolve after reset/dispose and after another client has already committed
|
|
1206
|
+
// a newer journey into the same storage. The old instance must not schedule a retry, rewrite
|
|
1207
|
+
// state, or emit action_applied regardless of whether the callback returned true/false/threw.
|
|
1208
|
+
if (!lifecycleIsCurrent()) return false;
|
|
1209
|
+
|
|
1210
|
+
const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
|
|
1211
|
+
if (applicationFailed) {
|
|
1212
|
+
reportError(applicationError);
|
|
1213
|
+
if (!lifecycleIsCurrent()) return false;
|
|
1214
|
+
// onError is host code too: it may capture a newer deterministic destination while reporting
|
|
1215
|
+
// this failure. Re-evaluate after the callback instead of arming a stale retry from the value
|
|
1216
|
+
// observed before diagnostics ran.
|
|
1217
|
+
if (hasNewerPendingIntent()) {
|
|
1218
|
+
// The current action did not commit. Resume the newer accepted intent that was captured
|
|
1219
|
+
// while its callback was suspended, without scheduling a retry for the superseded one.
|
|
1220
|
+
void process();
|
|
1221
|
+
return false;
|
|
1222
|
+
}
|
|
757
1223
|
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();
|
|
758
1224
|
else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();
|
|
759
1225
|
else scheduleAccountRetry();
|
|
760
1226
|
return false;
|
|
761
1227
|
}
|
|
762
1228
|
if (applied === false) {
|
|
1229
|
+
if (newerIntentArrivedDuringApplication) {
|
|
1230
|
+
void process();
|
|
1231
|
+
return false;
|
|
1232
|
+
}
|
|
763
1233
|
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();
|
|
764
1234
|
else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();
|
|
765
1235
|
else scheduleAccountRetry();
|
|
766
1236
|
return false;
|
|
767
1237
|
}
|
|
768
1238
|
|
|
1239
|
+
if (newerIntentArrivedDuringApplication) {
|
|
1240
|
+
// Invocation of any host routing callback is the last safe commit boundary. A successful
|
|
1241
|
+
// callback may already have navigated, so automatically applying the newly captured token
|
|
1242
|
+
// now would create two journeys. Keep it pending and let a fresh SDK lifecycle recover it.
|
|
1243
|
+
deterministicTokenDeferredAfterCommittedJourney = state.pending!.token;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
769
1246
|
const newlyApplied = sourceToken && sourceToken !== link.id ? [link.id, sourceToken] : [link.id];
|
|
770
1247
|
const newlyAppliedSet = new Set(newlyApplied);
|
|
771
1248
|
const appliedIds = [...state.appliedIds.filter((id) => !newlyAppliedSet.has(id)), ...newlyApplied]
|
|
772
1249
|
.slice(-MAX_APPLIED_IDS);
|
|
773
|
-
const stillPending = sourceToken !== null
|
|
1250
|
+
const stillPending = sourceToken !== null
|
|
1251
|
+
? state.pending?.token !== sourceToken
|
|
1252
|
+
: newerIntentArrivedDuringApplication;
|
|
774
1253
|
state = {
|
|
775
1254
|
...state,
|
|
776
1255
|
status: stillPending ? 'pending' : 'applied',
|
|
@@ -779,12 +1258,16 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
779
1258
|
appliedIds,
|
|
780
1259
|
};
|
|
781
1260
|
persistAndNotify();
|
|
1261
|
+
if (!lifecycleIsCurrent()) return false;
|
|
782
1262
|
if (link.matchBasis === 'account_bound') {
|
|
783
1263
|
accountAttempts = 0;
|
|
784
1264
|
accountRetryAt = 0;
|
|
785
1265
|
}
|
|
786
1266
|
emitOutcome(link, 'action_applied');
|
|
787
|
-
if (
|
|
1267
|
+
if (!lifecycleIsCurrent()) return false;
|
|
1268
|
+
if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
|
|
1269
|
+
void process();
|
|
1270
|
+
}
|
|
788
1271
|
return true;
|
|
789
1272
|
};
|
|
790
1273
|
|
|
@@ -822,17 +1305,28 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
822
1305
|
|
|
823
1306
|
const processOnce = async (): Promise<void> => {
|
|
824
1307
|
if (disposed) return;
|
|
1308
|
+
// `capture()` can re-enter while any host callback is awaiting. Never start another routing
|
|
1309
|
+
// application concurrently, and never auto-drain the exact token preserved after an already
|
|
1310
|
+
// committed journey in this client lifecycle.
|
|
1311
|
+
if (routingApplicationInFlight) return;
|
|
825
1312
|
const pending = state.pending;
|
|
826
1313
|
if (pending) {
|
|
1314
|
+
if (pending.token === deterministicTokenDeferredAfterCommittedJourney) return;
|
|
827
1315
|
if (pending.nextRetryAt > now()) return;
|
|
828
1316
|
if (now() - pending.receivedAt > tokenMaxAgeMs) {
|
|
829
1317
|
clearPendingAsTerminal();
|
|
830
1318
|
return;
|
|
831
1319
|
}
|
|
832
1320
|
const token = pending.token;
|
|
1321
|
+
const pendingLifecycleGeneration = lifecycleGeneration;
|
|
833
1322
|
patchState({ status: 'resolving' });
|
|
1323
|
+
if (disposed
|
|
1324
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1325
|
+
|| state.pending?.token !== token) return;
|
|
834
1326
|
const publicResult = await resolvePublic(token, pending.matchBasis);
|
|
835
|
-
if (disposed
|
|
1327
|
+
if (disposed
|
|
1328
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1329
|
+
|| state.pending?.token !== token) return;
|
|
836
1330
|
if (publicResult.kind === 'resolved') {
|
|
837
1331
|
await applyResolved(publicResult.link, token);
|
|
838
1332
|
return;
|
|
@@ -842,13 +1336,28 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
842
1336
|
return;
|
|
843
1337
|
}
|
|
844
1338
|
|
|
845
|
-
if (!options.accountBridge
|
|
1339
|
+
if (!options.accountBridge) {
|
|
1340
|
+
patchState({ status: 'waiting_for_account' });
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
const accountReadiness = readAccountReadiness();
|
|
1344
|
+
if (!accountReadiness.current
|
|
1345
|
+
|| disposed
|
|
1346
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1347
|
+
|| state.pending?.token !== token) return;
|
|
1348
|
+
if (!accountReadiness.ready) {
|
|
846
1349
|
patchState({ status: 'waiting_for_account' });
|
|
847
1350
|
return;
|
|
848
1351
|
}
|
|
849
1352
|
try {
|
|
850
|
-
const raw = await
|
|
851
|
-
|
|
1353
|
+
const raw = await withPromiseTimeout(
|
|
1354
|
+
() => options.accountBridge!.claim(token),
|
|
1355
|
+
requestTimeoutMs,
|
|
1356
|
+
'account claim',
|
|
1357
|
+
);
|
|
1358
|
+
if (disposed
|
|
1359
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1360
|
+
|| state.pending?.token !== token) return;
|
|
852
1361
|
const normalizedClaim = normalizeResolved<Action>(
|
|
853
1362
|
raw,
|
|
854
1363
|
token,
|
|
@@ -862,18 +1371,42 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
862
1371
|
matchGuaranteed: true,
|
|
863
1372
|
confidence: 1,
|
|
864
1373
|
} : null;
|
|
865
|
-
if (claimed)
|
|
1374
|
+
if (claimed) {
|
|
1375
|
+
if (!blockFirstOpenForDeterministic()) return;
|
|
1376
|
+
await applyResolved(claimed, token);
|
|
1377
|
+
}
|
|
866
1378
|
else clearPendingAsTerminal();
|
|
867
1379
|
} catch (error) {
|
|
1380
|
+
if (disposed
|
|
1381
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1382
|
+
|| state.pending?.token !== token) return;
|
|
868
1383
|
reportError(error);
|
|
869
|
-
if (
|
|
1384
|
+
if (!disposed
|
|
1385
|
+
&& lifecycleGeneration === pendingLifecycleGeneration
|
|
1386
|
+
&& state.pending?.token === token) schedulePendingRetry();
|
|
870
1387
|
}
|
|
871
1388
|
return;
|
|
872
1389
|
}
|
|
873
1390
|
|
|
874
|
-
if (!options.accountBridge
|
|
1391
|
+
if (!options.accountBridge) return;
|
|
1392
|
+
const accountReadiness = readAccountReadiness();
|
|
1393
|
+
if (!accountReadiness.current || state.pending) return;
|
|
1394
|
+
if (!accountReadiness.ready || accountRetryAt > now()) return;
|
|
1395
|
+
const accountPendingEpoch = deterministicEpoch;
|
|
1396
|
+
const accountPendingLifecycleGeneration = lifecycleGeneration;
|
|
875
1397
|
try {
|
|
876
|
-
const raw = await
|
|
1398
|
+
const raw = await withPromiseTimeout(
|
|
1399
|
+
() => options.accountBridge!.pending(),
|
|
1400
|
+
requestTimeoutMs,
|
|
1401
|
+
'account pending',
|
|
1402
|
+
);
|
|
1403
|
+
// A URL/paste/referrer captured while the account lookup was in flight is newer explicit
|
|
1404
|
+
// user intent. Ignore the stale bridge response and let the requested drain resolve the
|
|
1405
|
+
// captured token; otherwise both deterministic destinations could be applied.
|
|
1406
|
+
if (disposed
|
|
1407
|
+
|| lifecycleGeneration !== accountPendingLifecycleGeneration
|
|
1408
|
+
|| deterministicEpoch !== accountPendingEpoch
|
|
1409
|
+
|| state.pending) return;
|
|
877
1410
|
if (!raw) {
|
|
878
1411
|
accountAttempts = 0;
|
|
879
1412
|
accountRetryAt = 0;
|
|
@@ -898,13 +1431,27 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
898
1431
|
matchGuaranteed: true,
|
|
899
1432
|
confidence: 1,
|
|
900
1433
|
} : null;
|
|
901
|
-
if (pendingLink
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1434
|
+
if (pendingLink) {
|
|
1435
|
+
// Reserve the first-open journey for deterministic recovery before invoking any host
|
|
1436
|
+
// callback. This also covers an already-applied account item restored from older SDK
|
|
1437
|
+
// state, which must still prevent a second probabilistic destination.
|
|
1438
|
+
if (!blockFirstOpenForDeterministic()) return;
|
|
1439
|
+
if (!state.appliedIds.includes(pendingLink.id)) {
|
|
1440
|
+
await applyResolved(pendingLink, null);
|
|
1441
|
+
} else if (state.status !== 'applied') {
|
|
1442
|
+
patchState({ status: 'applied' });
|
|
1443
|
+
}
|
|
905
1444
|
}
|
|
906
1445
|
} catch (error) {
|
|
1446
|
+
if (disposed
|
|
1447
|
+
|| lifecycleGeneration !== accountPendingLifecycleGeneration
|
|
1448
|
+
|| deterministicEpoch !== accountPendingEpoch
|
|
1449
|
+
|| state.pending) return;
|
|
907
1450
|
reportError(error);
|
|
1451
|
+
if (disposed
|
|
1452
|
+
|| lifecycleGeneration !== accountPendingLifecycleGeneration
|
|
1453
|
+
|| deterministicEpoch !== accountPendingEpoch
|
|
1454
|
+
|| state.pending) return;
|
|
908
1455
|
// No token was consumed; the authenticated server outbox remains authoritative.
|
|
909
1456
|
scheduleAccountRetry();
|
|
910
1457
|
}
|
|
@@ -912,6 +1459,8 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
912
1459
|
|
|
913
1460
|
const process = async (): Promise<void> => {
|
|
914
1461
|
if (disposed) return;
|
|
1462
|
+
// Application delivery is independent: never await it on the routing drain.
|
|
1463
|
+
void flushTerminalDelivery();
|
|
915
1464
|
processRequested = true;
|
|
916
1465
|
if (processing) return processing;
|
|
917
1466
|
const run = (async () => {
|
|
@@ -938,11 +1487,13 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
938
1487
|
const token = normalizeDeferredHandoffToken(rawToken);
|
|
939
1488
|
if (!token) return false;
|
|
940
1489
|
if (state.appliedIds.includes(token)) {
|
|
1490
|
+
deterministicEpoch += 1;
|
|
941
1491
|
if (!state.firstOpen.completed) patchFirstOpen({ completed: true, nextRetryAt: 0 });
|
|
942
1492
|
return true;
|
|
943
1493
|
}
|
|
944
1494
|
if (state.pending?.token === token) {
|
|
945
|
-
|
|
1495
|
+
deterministicEpoch += 1;
|
|
1496
|
+
if (CAPTURE_PRIORITY[basis] > CAPTURE_PRIORITY[state.pending.matchBasis]) {
|
|
946
1497
|
state = {
|
|
947
1498
|
...state,
|
|
948
1499
|
pending: { ...state.pending, matchBasis: basis },
|
|
@@ -955,6 +1506,19 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
955
1506
|
void process();
|
|
956
1507
|
return true;
|
|
957
1508
|
}
|
|
1509
|
+
|
|
1510
|
+
// Exact transports are deterministic, but when more than one arrives during cold start their
|
|
1511
|
+
// provenance still has an explicit precedence. A lower-priority late callback must not replace
|
|
1512
|
+
// the URL the user intentionally opened; equal priority keeps last-touch behaviour.
|
|
1513
|
+
if (state.pending
|
|
1514
|
+
&& CAPTURE_PRIORITY[basis] < CAPTURE_PRIORITY[state.pending.matchBasis]) {
|
|
1515
|
+
deterministicEpoch += 1;
|
|
1516
|
+
if (!state.firstOpen.completed) patchFirstOpen({ completed: true, nextRetryAt: 0 });
|
|
1517
|
+
void process();
|
|
1518
|
+
return true;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
deterministicEpoch += 1;
|
|
958
1522
|
state = {
|
|
959
1523
|
...state,
|
|
960
1524
|
status: 'pending',
|
|
@@ -976,18 +1540,45 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
976
1540
|
return true;
|
|
977
1541
|
};
|
|
978
1542
|
|
|
979
|
-
const
|
|
1543
|
+
const matchFirstOpenOnce = async (
|
|
980
1544
|
context: AnonymousFirstOpenContext,
|
|
981
1545
|
): Promise<AnonymousFirstOpenResult> => {
|
|
982
1546
|
if (disposed) return 'ineligible';
|
|
983
1547
|
lastFirstOpenContext = context;
|
|
984
1548
|
if (state.pending) return 'deterministic_pending';
|
|
985
1549
|
if (state.firstOpen.completed) return 'already_completed';
|
|
986
|
-
if (state.firstOpen.nextRetryAt > now())
|
|
1550
|
+
if (state.firstOpen.nextRetryAt > now()) {
|
|
1551
|
+
// On a fresh client lifecycle scheduleWake() ran before the host supplied this ephemeral
|
|
1552
|
+
// context, so the persisted first-open deadline could not be part of its candidates. Re-arm
|
|
1553
|
+
// now that retrying is possible; otherwise a 503 followed by process death remains asleep
|
|
1554
|
+
// until some unrelated foreground/manual call invokes matchFirstOpen again.
|
|
1555
|
+
scheduleWake();
|
|
1556
|
+
return 'backoff';
|
|
1557
|
+
}
|
|
1558
|
+
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
1559
|
+
|
|
1560
|
+
// The client starts account recovery at construction. Join that deterministic rail before
|
|
1561
|
+
// asking the probabilistic matcher; otherwise a slow Billing/account response can navigate
|
|
1562
|
+
// after the probabilistic destination and the user observes both journeys.
|
|
1563
|
+
await process();
|
|
1564
|
+
if (disposed) return 'ineligible';
|
|
1565
|
+
if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1566
|
+
if (options.accountBridge) {
|
|
1567
|
+
const accountReadiness = readAccountReadiness();
|
|
1568
|
+
if (!accountReadiness.current) {
|
|
1569
|
+
return disposed ? 'ineligible' : 'deterministic_pending';
|
|
1570
|
+
}
|
|
1571
|
+
if (accountReadiness.ready && accountRetryAt > now()) {
|
|
1572
|
+
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
1573
|
+
return 'retry_scheduled';
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
987
1576
|
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
988
1577
|
|
|
989
1578
|
const installAttemptId = state.firstOpen.installAttemptId
|
|
990
1579
|
?? makeInstallAttemptId(options.randomUUID);
|
|
1580
|
+
const startingDeterministicEpoch = deterministicEpoch;
|
|
1581
|
+
const startingLifecycleGeneration = lifecycleGeneration;
|
|
991
1582
|
state = {
|
|
992
1583
|
...state,
|
|
993
1584
|
status: 'resolving',
|
|
@@ -997,8 +1588,35 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
997
1588
|
attemptedAt: now(),
|
|
998
1589
|
},
|
|
999
1590
|
};
|
|
1000
|
-
persistAndNotify();
|
|
1591
|
+
const firstOpenReservationPersisted = persistAndNotify();
|
|
1592
|
+
// Persistence notifies host state hooks synchronously. They may reset/dispose this client or
|
|
1593
|
+
// capture a deterministic destination. In all three cases the reserved id no longer belongs to
|
|
1594
|
+
// the current lifecycle and must never cross the network boundary.
|
|
1595
|
+
if (disposed) return 'ineligible';
|
|
1596
|
+
if (lifecycleGeneration !== startingLifecycleGeneration
|
|
1597
|
+
|| deterministicEpoch !== startingDeterministicEpoch
|
|
1598
|
+
|| state.pending
|
|
1599
|
+
|| state.firstOpen.completed) return 'deterministic_pending';
|
|
1600
|
+
if (options.storage && !firstOpenReservationPersisted) {
|
|
1601
|
+
// An unpersisted id must never leave the process: after a crash the retry would mint a new
|
|
1602
|
+
// id and the server could count/route the same install twice. Keep the exact nonce in memory,
|
|
1603
|
+
// arm a persistence retry without incrementing matcher attempts, and send nothing until a
|
|
1604
|
+
// synchronous durable write succeeds. Memory-only clients have no restart contract and may
|
|
1605
|
+
// continue normally.
|
|
1606
|
+
state = {
|
|
1607
|
+
...state,
|
|
1608
|
+
status: 'retryable_error',
|
|
1609
|
+
firstOpen: {
|
|
1610
|
+
...state.firstOpen,
|
|
1611
|
+
nextRetryAt: now() + retryBaseMs,
|
|
1612
|
+
},
|
|
1613
|
+
};
|
|
1614
|
+
reportError(new Error('Pulse Links: first-open install attempt id was not durably persisted'));
|
|
1615
|
+
notifyState();
|
|
1616
|
+
return 'retry_scheduled';
|
|
1617
|
+
}
|
|
1001
1618
|
|
|
1619
|
+
const accountEmail = normalizeAccountEmail(context.accountEmail);
|
|
1002
1620
|
const body = {
|
|
1003
1621
|
appBundleId: context.appBundleId.trim(),
|
|
1004
1622
|
platform: 'ios' as const,
|
|
@@ -1006,6 +1624,10 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1006
1624
|
firstOpen: true,
|
|
1007
1625
|
installAttemptId,
|
|
1008
1626
|
...normalizeAnonymousFirstOpenSignals(context),
|
|
1627
|
+
// Sent only when the app already knows it. The resolver treats an address that matches
|
|
1628
|
+
// exactly one recent message as proof of origin; anything else falls back to the
|
|
1629
|
+
// probabilistic path, so a shared or unknown address costs nothing.
|
|
1630
|
+
...(accountEmail ? { accountEmail } : {}),
|
|
1009
1631
|
};
|
|
1010
1632
|
try {
|
|
1011
1633
|
const response = await withTimeout(
|
|
@@ -1018,16 +1640,27 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1018
1640
|
},
|
|
1019
1641
|
requestTimeoutMs,
|
|
1020
1642
|
);
|
|
1021
|
-
if (
|
|
1022
|
-
if (
|
|
1643
|
+
if (disposed) return 'ineligible';
|
|
1644
|
+
if (deterministicEpoch !== startingDeterministicEpoch
|
|
1645
|
+
|| state.pending
|
|
1646
|
+
|| state.firstOpen.completed) return 'deterministic_pending';
|
|
1023
1647
|
if (response.status === 204) {
|
|
1024
|
-
|
|
1025
|
-
|
|
1648
|
+
completeFirstOpen('idle', {
|
|
1649
|
+
status: 'NOT_FOUND',
|
|
1650
|
+
rail: 'no_route',
|
|
1651
|
+
routed: false,
|
|
1652
|
+
reason: firstOpenNoRouteReason(response),
|
|
1653
|
+
});
|
|
1026
1654
|
return 'no_match';
|
|
1027
1655
|
}
|
|
1028
|
-
if (response.status === 400 || response.status === 404
|
|
1029
|
-
|
|
1030
|
-
|
|
1656
|
+
if (response.status === 400 || response.status === 404
|
|
1657
|
+
|| response.status === 410 || response.status === 413) {
|
|
1658
|
+
completeFirstOpen('terminal_error', {
|
|
1659
|
+
status: 'FAILURE',
|
|
1660
|
+
rail: 'no_route',
|
|
1661
|
+
routed: false,
|
|
1662
|
+
reason: 'invalid_request',
|
|
1663
|
+
});
|
|
1031
1664
|
return 'terminal_error';
|
|
1032
1665
|
}
|
|
1033
1666
|
if (!response.ok) {
|
|
@@ -1035,41 +1668,118 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1035
1668
|
scheduleFirstOpenRetry();
|
|
1036
1669
|
return 'retry_scheduled';
|
|
1037
1670
|
}
|
|
1038
|
-
|
|
1039
|
-
|
|
1671
|
+
completeFirstOpen('terminal_error', {
|
|
1672
|
+
status: 'FAILURE',
|
|
1673
|
+
rail: 'no_route',
|
|
1674
|
+
routed: false,
|
|
1675
|
+
reason: 'invalid_response',
|
|
1676
|
+
});
|
|
1677
|
+
return 'terminal_error';
|
|
1678
|
+
}
|
|
1679
|
+
let rawLink: unknown;
|
|
1680
|
+
try {
|
|
1681
|
+
rawLink = await response.json();
|
|
1682
|
+
} catch {
|
|
1683
|
+
completeFirstOpen('terminal_error', {
|
|
1684
|
+
status: 'FAILURE',
|
|
1685
|
+
rail: 'no_route',
|
|
1686
|
+
routed: false,
|
|
1687
|
+
reason: 'invalid_response',
|
|
1688
|
+
});
|
|
1040
1689
|
return 'terminal_error';
|
|
1041
1690
|
}
|
|
1691
|
+
if (disposed) return 'ineligible';
|
|
1692
|
+
if (deterministicEpoch !== startingDeterministicEpoch
|
|
1693
|
+
|| state.pending
|
|
1694
|
+
|| state.firstOpen.completed) return 'deterministic_pending';
|
|
1042
1695
|
const link = normalizeResolved<Action>(
|
|
1043
|
-
|
|
1696
|
+
rawLink,
|
|
1044
1697
|
installAttemptId.replace(/-/g, ''),
|
|
1045
1698
|
'unique_probabilistic',
|
|
1046
1699
|
allowedActions,
|
|
1047
1700
|
now(),
|
|
1048
1701
|
);
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1702
|
+
if (!link) {
|
|
1703
|
+
completeFirstOpen('terminal_error', {
|
|
1704
|
+
status: 'FAILURE',
|
|
1705
|
+
rail: 'no_route',
|
|
1706
|
+
routed: false,
|
|
1707
|
+
reason: 'invalid_response',
|
|
1708
|
+
});
|
|
1055
1709
|
return 'terminal_error';
|
|
1056
1710
|
}
|
|
1711
|
+
// A probabilistic result is navigation intent, never a remote URL transport. Only the two
|
|
1712
|
+
// harmless closed discovery actions are accepted; all billing/account/entitlement actions
|
|
1713
|
+
// and even an app-owned deepLink are rejected before application code runs.
|
|
1714
|
+
if (link.matchBasis !== 'unique_probabilistic'
|
|
1715
|
+
|| link.matchGuaranteed
|
|
1716
|
+
|| link.deepLink !== undefined
|
|
1717
|
+
|| !link.action
|
|
1718
|
+
|| !PROBABILISTIC_ACTIONS.has(String(link.action))
|
|
1719
|
+
|| sensitiveActions.has(link.action)
|
|
1720
|
+
|| !options.onAction) {
|
|
1721
|
+
completeFirstOpen('terminal_error', {
|
|
1722
|
+
status: 'FAILURE',
|
|
1723
|
+
rail: 'no_route',
|
|
1724
|
+
routed: false,
|
|
1725
|
+
reason: 'policy_rejected',
|
|
1726
|
+
});
|
|
1727
|
+
return 'terminal_error';
|
|
1728
|
+
}
|
|
1729
|
+
if (deterministicEpoch !== startingDeterministicEpoch
|
|
1730
|
+
|| state.pending
|
|
1731
|
+
|| state.firstOpen.completed) return 'deterministic_pending';
|
|
1057
1732
|
if (await applyResolved(link, null)) {
|
|
1058
|
-
|
|
1733
|
+
// `applyResolved()` returns true only after the host callback crossed its irreversible
|
|
1734
|
+
// commit boundary successfully. A deterministic capture can arrive while that callback
|
|
1735
|
+
// awaits; it remains pending for the next lifecycle, but it must not erase the terminal
|
|
1736
|
+
// truth that this probabilistic journey really routed.
|
|
1737
|
+
completeFirstOpen(state.pending ? 'pending' : 'applied', {
|
|
1738
|
+
status: 'FOUND',
|
|
1739
|
+
rail: 'fast_route',
|
|
1740
|
+
routed: true,
|
|
1741
|
+
reason: 'matched',
|
|
1742
|
+
matchBasis: link.matchBasis,
|
|
1743
|
+
confidence: link.confidence,
|
|
1744
|
+
...(link.campaignId !== undefined ? { campaignId: link.campaignId } : {}),
|
|
1745
|
+
...(link.experimentId !== undefined ? { experimentId: link.experimentId } : {}),
|
|
1746
|
+
...(link.variantId !== undefined ? { variantId: link.variantId } : {}),
|
|
1747
|
+
});
|
|
1059
1748
|
return 'matched';
|
|
1060
1749
|
}
|
|
1750
|
+
if (deterministicEpoch !== startingDeterministicEpoch
|
|
1751
|
+
|| state.pending
|
|
1752
|
+
|| state.firstOpen.completed) return 'deterministic_pending';
|
|
1061
1753
|
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
1062
1754
|
return 'retry_scheduled';
|
|
1063
1755
|
} catch (error) {
|
|
1756
|
+
if (disposed) return 'ineligible';
|
|
1064
1757
|
reportError(error);
|
|
1758
|
+
if (deterministicEpoch !== startingDeterministicEpoch
|
|
1759
|
+
|| state.pending
|
|
1760
|
+
|| state.firstOpen.completed) return 'deterministic_pending';
|
|
1065
1761
|
scheduleFirstOpenRetry();
|
|
1066
1762
|
return 'retry_scheduled';
|
|
1067
1763
|
}
|
|
1068
1764
|
};
|
|
1069
1765
|
|
|
1766
|
+
const matchFirstOpen = (
|
|
1767
|
+
context: AnonymousFirstOpenContext,
|
|
1768
|
+
): Promise<AnonymousFirstOpenResult> => {
|
|
1769
|
+
if (firstOpenMatching) return firstOpenMatching;
|
|
1770
|
+
let owned: Promise<AnonymousFirstOpenResult>;
|
|
1771
|
+
owned = matchFirstOpenOnce(context).finally(() => {
|
|
1772
|
+
if (firstOpenMatching === owned) firstOpenMatching = null;
|
|
1773
|
+
});
|
|
1774
|
+
firstOpenMatching = owned;
|
|
1775
|
+
return owned;
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1070
1778
|
const captureAndroidInstallReferrer = async (
|
|
1071
1779
|
bridge: AndroidInstallReferrerBridge,
|
|
1072
1780
|
): Promise<AndroidInstallReferrerResult> => {
|
|
1781
|
+
const referrerLifecycleGeneration = lifecycleGeneration;
|
|
1782
|
+
const referrerDeterministicEpoch = deterministicEpoch;
|
|
1073
1783
|
let raw: unknown;
|
|
1074
1784
|
try {
|
|
1075
1785
|
raw = await bridge.getDeferredHandoff();
|
|
@@ -1078,6 +1788,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1078
1788
|
return { status: 'SERVICE_UNAVAILABLE', retryable: true, attempts: 0 };
|
|
1079
1789
|
}
|
|
1080
1790
|
const result = normalizeAndroidInstallReferrerResult(raw);
|
|
1791
|
+
if (disposed
|
|
1792
|
+
|| lifecycleGeneration !== referrerLifecycleGeneration
|
|
1793
|
+
|| deterministicEpoch !== referrerDeterministicEpoch) return result;
|
|
1081
1794
|
if (result.status === 'OK' && result.token) {
|
|
1082
1795
|
capture(result.token, 'android_install_referrer');
|
|
1083
1796
|
}
|
|
@@ -1095,6 +1808,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1095
1808
|
lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0,
|
|
1096
1809
|
accountRetryAt,
|
|
1097
1810
|
state.outcomeQueue[0]?.nextRetryAt ?? 0,
|
|
1811
|
+
state.terminalDelivery?.nextRetryAt ?? 0,
|
|
1098
1812
|
].filter((value) => value > now());
|
|
1099
1813
|
if (candidates.length === 0) return;
|
|
1100
1814
|
const next = Math.min(...candidates);
|
|
@@ -1102,6 +1816,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1102
1816
|
retryTimer = null;
|
|
1103
1817
|
void process();
|
|
1104
1818
|
void flushResolverOutcomes();
|
|
1819
|
+
void flushTerminalDelivery();
|
|
1105
1820
|
if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
|
|
1106
1821
|
void matchFirstOpen(lastFirstOpenContext);
|
|
1107
1822
|
}
|
|
@@ -1120,6 +1835,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1120
1835
|
scheduleWake();
|
|
1121
1836
|
void process();
|
|
1122
1837
|
void flushResolverOutcomes();
|
|
1838
|
+
void flushTerminalDelivery();
|
|
1123
1839
|
|
|
1124
1840
|
return {
|
|
1125
1841
|
capture,
|
|
@@ -1138,6 +1854,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1138
1854
|
dispose: () => {
|
|
1139
1855
|
if (disposed) return;
|
|
1140
1856
|
disposed = true;
|
|
1857
|
+
lifecycleGeneration += 1;
|
|
1858
|
+
deterministicEpoch += 1;
|
|
1859
|
+
terminalDeliveryGeneration += 1;
|
|
1141
1860
|
if (retryTimer) clearTimeout(retryTimer);
|
|
1142
1861
|
retryTimer = null;
|
|
1143
1862
|
unsubscribeAccount?.();
|
|
@@ -1147,6 +1866,11 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1147
1866
|
reset: () => {
|
|
1148
1867
|
accountRetryAt = 0;
|
|
1149
1868
|
accountAttempts = 0;
|
|
1869
|
+
lifecycleGeneration += 1;
|
|
1870
|
+
deterministicEpoch += 1;
|
|
1871
|
+
terminalDeliveryGeneration += 1;
|
|
1872
|
+
terminalDeliverySending = null;
|
|
1873
|
+
deterministicTokenDeferredAfterCommittedJourney = null;
|
|
1150
1874
|
state = emptyState();
|
|
1151
1875
|
persistAndNotify();
|
|
1152
1876
|
},
|
|
@@ -1199,11 +1923,11 @@ function stableOutcomeKey(linkId: string, name: DeferredLinkOutcomeName): string
|
|
|
1199
1923
|
return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}|${name}`;
|
|
1200
1924
|
}
|
|
1201
1925
|
|
|
1202
|
-
function boundedMetadata(value: unknown): string | null | undefined {
|
|
1926
|
+
function boundedMetadata(value: unknown, maxLength = 256): string | null | undefined {
|
|
1203
1927
|
if (value === null) return null;
|
|
1204
1928
|
if (typeof value !== 'string' && typeof value !== 'number') return undefined;
|
|
1205
1929
|
const result = String(value).trim();
|
|
1206
|
-
return result.length > 0 && result.length <=
|
|
1930
|
+
return result.length > 0 && result.length <= maxLength ? result : undefined;
|
|
1207
1931
|
}
|
|
1208
1932
|
|
|
1209
1933
|
function normalizeResolved<Action extends string>(
|
|
@@ -1246,9 +1970,9 @@ function normalizeResolved<Action extends string>(
|
|
|
1246
1970
|
if (!Number.isFinite(expiry) || expiry <= now) return null;
|
|
1247
1971
|
}
|
|
1248
1972
|
const source = boundedMetadata(value.source);
|
|
1249
|
-
const campaignId = boundedMetadata(value.campaignId);
|
|
1250
|
-
const experimentId = boundedMetadata(value.experimentId);
|
|
1251
|
-
const variantId = boundedMetadata(value.variantId);
|
|
1973
|
+
const campaignId = boundedMetadata(value.campaignId, 128);
|
|
1974
|
+
const experimentId = boundedMetadata(value.experimentId, 128);
|
|
1975
|
+
const variantId = boundedMetadata(value.variantId, 128);
|
|
1252
1976
|
return {
|
|
1253
1977
|
id,
|
|
1254
1978
|
...(action ? { action } : {}),
|
|
@@ -1286,6 +2010,15 @@ function normalizeLocale(locale: string): string {
|
|
|
1286
2010
|
return locale.trim().replace(/_/g, '-');
|
|
1287
2011
|
}
|
|
1288
2012
|
|
|
2013
|
+
function firstOpenNoRouteReason(response: Response): AnonymousFirstOpenTerminalReason {
|
|
2014
|
+
let raw = '';
|
|
2015
|
+
try { raw = response.headers?.get('X-Encore-Match-Outcome')?.trim().toLowerCase() ?? ''; }
|
|
2016
|
+
catch { raw = ''; }
|
|
2017
|
+
return NO_ROUTE_REASONS.has(raw as AnonymousFirstOpenTerminalReason)
|
|
2018
|
+
? raw as AnonymousFirstOpenTerminalReason
|
|
2019
|
+
: 'unmatched';
|
|
2020
|
+
}
|
|
2021
|
+
|
|
1289
2022
|
function normalizeAnonymousFirstOpenSignals(
|
|
1290
2023
|
context: AnonymousFirstOpenContext,
|
|
1291
2024
|
): Partial<Omit<AnonymousFirstOpenContext, 'appBundleId' | 'locale' | 'platform' | 'installedAt'>> {
|
|
@@ -1382,6 +2115,20 @@ function resolverOutcomeRetryDelay(
|
|
|
1382
2115
|
return Math.max(1, Math.floor(ceiling * jitter));
|
|
1383
2116
|
}
|
|
1384
2117
|
|
|
2118
|
+
/**
|
|
2119
|
+
* An address is only useful to the resolver if it is the same shape the send was recorded with.
|
|
2120
|
+
* Anything that is not plausibly an address is dropped rather than sent: a malformed value can
|
|
2121
|
+
* only ever fail to match, and not sending it keeps the payload free of stray user input.
|
|
2122
|
+
*/
|
|
2123
|
+
export function normalizeAccountEmail(raw: string | undefined | null): string | null {
|
|
2124
|
+
const value = (raw ?? '').trim().toLowerCase();
|
|
2125
|
+
if (value.length < 3 || value.length > 255) return null;
|
|
2126
|
+
const at = value.indexOf('@');
|
|
2127
|
+
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return null;
|
|
2128
|
+
if (/\s/.test(value)) return null;
|
|
2129
|
+
return value;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
1385
2132
|
async function withTimeout(
|
|
1386
2133
|
fetcher: typeof fetch,
|
|
1387
2134
|
input: string,
|
|
@@ -1397,6 +2144,55 @@ async function withTimeout(
|
|
|
1397
2144
|
}
|
|
1398
2145
|
}
|
|
1399
2146
|
|
|
2147
|
+
async function withPromiseTimeout<T>(
|
|
2148
|
+
factory: () => Promise<T>,
|
|
2149
|
+
timeoutMs: number,
|
|
2150
|
+
operation: 'account claim' | 'account pending',
|
|
2151
|
+
): Promise<T> {
|
|
2152
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
2153
|
+
try {
|
|
2154
|
+
return await Promise.race([
|
|
2155
|
+
Promise.resolve().then(factory),
|
|
2156
|
+
new Promise<T>((_resolve, reject) => {
|
|
2157
|
+
timer = setTimeout(
|
|
2158
|
+
() => reject(new Error(`Pulse Links: ${operation} timed out`)),
|
|
2159
|
+
timeoutMs,
|
|
2160
|
+
);
|
|
2161
|
+
}),
|
|
2162
|
+
]);
|
|
2163
|
+
} finally {
|
|
2164
|
+
if (timer) clearTimeout(timer);
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
async function withTerminalDeliveryTimeout(
|
|
2169
|
+
acknowledgement: Promise<AnonymousFirstOpenTerminalDisposition | boolean>,
|
|
2170
|
+
timeoutMs: number,
|
|
2171
|
+
): Promise<AnonymousFirstOpenTerminalDisposition | boolean> {
|
|
2172
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
2173
|
+
try {
|
|
2174
|
+
return await Promise.race([
|
|
2175
|
+
acknowledgement,
|
|
2176
|
+
new Promise<AnonymousFirstOpenTerminalDisposition | boolean>((_resolve, reject) => {
|
|
2177
|
+
timer = setTimeout(
|
|
2178
|
+
() => reject(new Error('Pulse Links: terminal delivery acknowledgement timed out')),
|
|
2179
|
+
timeoutMs,
|
|
2180
|
+
);
|
|
2181
|
+
}),
|
|
2182
|
+
]);
|
|
2183
|
+
} finally {
|
|
2184
|
+
if (timer) clearTimeout(timer);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
function normalizeTerminalDeliveryDisposition(
|
|
2189
|
+
value: AnonymousFirstOpenTerminalDisposition | boolean,
|
|
2190
|
+
): AnonymousFirstOpenTerminalDisposition {
|
|
2191
|
+
if (value === true || value === 'accepted') return 'accepted';
|
|
2192
|
+
if (value === 'drop') return 'drop';
|
|
2193
|
+
return 'retry';
|
|
2194
|
+
}
|
|
2195
|
+
|
|
1400
2196
|
async function postResolverOutcome(
|
|
1401
2197
|
fetcher: typeof fetch,
|
|
1402
2198
|
resolverBaseUrl: string,
|
|
@@ -1488,6 +2284,117 @@ function normalizeOccurredAt(value: unknown): string | null {
|
|
|
1488
2284
|
return new Date(timestamp).toISOString() === value ? value : null;
|
|
1489
2285
|
}
|
|
1490
2286
|
|
|
2287
|
+
function readTerminalDelivery(raw: unknown): PersistedFirstOpenTerminalDelivery | null {
|
|
2288
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
2289
|
+
const value = raw as Record<string, unknown>;
|
|
2290
|
+
const eventId = typeof value.eventId === 'string' && UUID_V4.test(value.eventId)
|
|
2291
|
+
? value.eventId.toLowerCase()
|
|
2292
|
+
: null;
|
|
2293
|
+
const status = value.status === 'FOUND' || value.status === 'NOT_FOUND' || value.status === 'FAILURE'
|
|
2294
|
+
? value.status
|
|
2295
|
+
: null;
|
|
2296
|
+
const rail = value.rail === 'fast_route' || value.rail === 'no_route' ? value.rail : null;
|
|
2297
|
+
const reason = typeof value.reason === 'string'
|
|
2298
|
+
? value.reason as AnonymousFirstOpenTerminalReason
|
|
2299
|
+
: null;
|
|
2300
|
+
const occurredAt = normalizeOccurredAt(value.occurredAt);
|
|
2301
|
+
const attempts = typeof value.attempts === 'number'
|
|
2302
|
+
&& Number.isSafeInteger(value.attempts)
|
|
2303
|
+
&& value.attempts >= 0
|
|
2304
|
+
&& value.attempts <= 100_000
|
|
2305
|
+
? value.attempts
|
|
2306
|
+
: null;
|
|
2307
|
+
const nextRetryAt = typeof value.nextRetryAt === 'number'
|
|
2308
|
+
&& Number.isSafeInteger(value.nextRetryAt)
|
|
2309
|
+
&& value.nextRetryAt >= 0
|
|
2310
|
+
&& value.nextRetryAt <= 8_640_000_000_000_000
|
|
2311
|
+
? value.nextRetryAt
|
|
2312
|
+
: null;
|
|
2313
|
+
if (value.disposition === 'drop') {
|
|
2314
|
+
const allowedKeys = new Set(['eventId', 'disposition', 'attempts', 'nextRetryAt']);
|
|
2315
|
+
if (!eventId
|
|
2316
|
+
|| attempts === null
|
|
2317
|
+
|| nextRetryAt === null
|
|
2318
|
+
|| Object.keys(value).some((key) => !allowedKeys.has(key))) return null;
|
|
2319
|
+
return { eventId, disposition: 'drop', attempts, nextRetryAt };
|
|
2320
|
+
}
|
|
2321
|
+
const matchBasis = typeof value.matchBasis === 'string'
|
|
2322
|
+
&& MATCH_BASES.has(value.matchBasis as DeferredLinkMatchBasis)
|
|
2323
|
+
? value.matchBasis as DeferredLinkMatchBasis
|
|
2324
|
+
: null;
|
|
2325
|
+
const confidence = typeof value.confidence === 'number'
|
|
2326
|
+
&& Number.isFinite(value.confidence)
|
|
2327
|
+
&& value.confidence >= 0
|
|
2328
|
+
&& value.confidence <= 1
|
|
2329
|
+
? value.confidence
|
|
2330
|
+
: null;
|
|
2331
|
+
const metadata: Partial<Pick<
|
|
2332
|
+
AnonymousFirstOpenTerminalOutcome,
|
|
2333
|
+
'campaignId' | 'experimentId' | 'variantId'
|
|
2334
|
+
>> = {};
|
|
2335
|
+
let metadataValid = true;
|
|
2336
|
+
for (const key of ['campaignId', 'experimentId', 'variantId'] as const) {
|
|
2337
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
|
|
2338
|
+
const field = value[key];
|
|
2339
|
+
if (field === null) metadata[key] = null;
|
|
2340
|
+
else if (typeof field === 'string'
|
|
2341
|
+
&& field.length > 0
|
|
2342
|
+
&& field.length <= 128
|
|
2343
|
+
&& field.trim() === field) metadata[key] = field;
|
|
2344
|
+
else metadataValid = false;
|
|
2345
|
+
}
|
|
2346
|
+
const hasFoundDimensions = Object.prototype.hasOwnProperty.call(value, 'matchBasis')
|
|
2347
|
+
&& Object.prototype.hasOwnProperty.call(value, 'confidence');
|
|
2348
|
+
const hasAnyAttributionDimension = Object.prototype.hasOwnProperty.call(value, 'matchBasis')
|
|
2349
|
+
|| Object.prototype.hasOwnProperty.call(value, 'confidence')
|
|
2350
|
+
|| Object.prototype.hasOwnProperty.call(value, 'campaignId')
|
|
2351
|
+
|| Object.prototype.hasOwnProperty.call(value, 'experimentId')
|
|
2352
|
+
|| Object.prototype.hasOwnProperty.call(value, 'variantId');
|
|
2353
|
+
const validSemanticOutcome = status === 'FOUND'
|
|
2354
|
+
? rail === 'fast_route'
|
|
2355
|
+
&& value.routed === true
|
|
2356
|
+
&& reason === 'matched'
|
|
2357
|
+
&& hasFoundDimensions
|
|
2358
|
+
&& matchBasis === 'unique_probabilistic'
|
|
2359
|
+
&& confidence !== null
|
|
2360
|
+
&& metadataValid
|
|
2361
|
+
: status === 'NOT_FOUND'
|
|
2362
|
+
? rail === 'no_route' && value.routed === false
|
|
2363
|
+
&& reason !== null && NO_ROUTE_REASONS.has(reason)
|
|
2364
|
+
&& !hasAnyAttributionDimension
|
|
2365
|
+
: status === 'FAILURE'
|
|
2366
|
+
? rail === 'no_route' && value.routed === false
|
|
2367
|
+
&& reason !== null && FAILURE_REASONS.has(reason)
|
|
2368
|
+
&& !hasAnyAttributionDimension
|
|
2369
|
+
: false;
|
|
2370
|
+
if (!eventId
|
|
2371
|
+
|| !status
|
|
2372
|
+
|| !rail
|
|
2373
|
+
|| !reason
|
|
2374
|
+
|| !occurredAt
|
|
2375
|
+
|| attempts === null
|
|
2376
|
+
|| nextRetryAt === null
|
|
2377
|
+
|| Object.prototype.hasOwnProperty.call(value, 'disposition')
|
|
2378
|
+
|| value.retryable !== false
|
|
2379
|
+
|| !validSemanticOutcome) return null;
|
|
2380
|
+
return {
|
|
2381
|
+
eventId,
|
|
2382
|
+
status,
|
|
2383
|
+
rail,
|
|
2384
|
+
routed: value.routed as boolean,
|
|
2385
|
+
retryable: false,
|
|
2386
|
+
reason,
|
|
2387
|
+
occurredAt,
|
|
2388
|
+
...(status === 'FOUND' ? {
|
|
2389
|
+
matchBasis: matchBasis as DeferredLinkMatchBasis,
|
|
2390
|
+
confidence: confidence as number,
|
|
2391
|
+
...metadata,
|
|
2392
|
+
} : {}),
|
|
2393
|
+
attempts,
|
|
2394
|
+
nextRetryAt,
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
|
|
1491
2398
|
function readState(storage: ConfigStorage | undefined, key: string): PersistedDeferredLinkState {
|
|
1492
2399
|
if (!storage) return emptyState();
|
|
1493
2400
|
try {
|
|
@@ -1535,6 +2442,7 @@ function readState(storage: ConfigStorage | undefined, key: string): PersistedDe
|
|
|
1535
2442
|
.slice(-MAX_NOTIFIED_OUTCOMES)
|
|
1536
2443
|
: [];
|
|
1537
2444
|
const outcomeQueue = readResolverOutcomeQueue(parsed.outcomeQueue);
|
|
2445
|
+
const terminalDelivery = readTerminalDelivery(parsed.terminalDelivery);
|
|
1538
2446
|
return {
|
|
1539
2447
|
version: 1,
|
|
1540
2448
|
status: status === 'resolving' ? (pending ? 'pending' : 'idle') : status,
|
|
@@ -1546,6 +2454,7 @@ function readState(storage: ConfigStorage | undefined, key: string): PersistedDe
|
|
|
1546
2454
|
...outcomeQueue.map((outcome) => outcome.transitionKey),
|
|
1547
2455
|
])].slice(-MAX_NOTIFIED_OUTCOMES),
|
|
1548
2456
|
outcomeQueue,
|
|
2457
|
+
terminalDelivery,
|
|
1549
2458
|
firstOpen: {
|
|
1550
2459
|
installAttemptId: attemptId,
|
|
1551
2460
|
completed: firstValue.completed === true,
|
|
@@ -1563,9 +2472,32 @@ function writeState(
|
|
|
1563
2472
|
storage: ConfigStorage | undefined,
|
|
1564
2473
|
key: string,
|
|
1565
2474
|
state: PersistedDeferredLinkState,
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
2475
|
+
allowKnownAsyncBestEffort = true,
|
|
2476
|
+
): boolean {
|
|
2477
|
+
if (!storage) return true;
|
|
2478
|
+
const knownAsync = storage.supportsDurableSyncWrites === false
|
|
2479
|
+
|| ASYNCHRONOUS_STORAGE_ADAPTERS.has(storage as object);
|
|
2480
|
+
if (knownAsync && !allowKnownAsyncBestEffort) return false;
|
|
2481
|
+
try {
|
|
2482
|
+
const result = (storage.set as unknown as (storageKey: string, value: string) => unknown)(
|
|
2483
|
+
key,
|
|
2484
|
+
JSON.stringify(state),
|
|
2485
|
+
);
|
|
2486
|
+
if (isThenable(result)) {
|
|
2487
|
+
ASYNCHRONOUS_STORAGE_ADAPTERS.add(storage as object);
|
|
2488
|
+
void Promise.resolve(result).catch(() => undefined);
|
|
2489
|
+
return false;
|
|
2490
|
+
}
|
|
2491
|
+
return !knownAsync;
|
|
2492
|
+
} catch {
|
|
2493
|
+
return false;
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
2498
|
+
return (typeof value === 'object' && value !== null) || typeof value === 'function'
|
|
2499
|
+
? typeof (value as { then?: unknown }).then === 'function'
|
|
2500
|
+
: false;
|
|
1569
2501
|
}
|
|
1570
2502
|
|
|
1571
2503
|
function safeTimestamp(value: unknown): number {
|