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/lib/commonjs/links.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.createDeferredLinkClient = void 0;
|
|
|
7
7
|
exports.createPulseLinkClient = createPulseLinkClient;
|
|
8
8
|
exports.extractDeferredHandoffToken = extractDeferredHandoffToken;
|
|
9
9
|
exports.isOpaqueDeferredLinkToken = isOpaqueDeferredLinkToken;
|
|
10
|
+
exports.normalizeAccountEmail = normalizeAccountEmail;
|
|
10
11
|
exports.normalizeAndroidInstallReferrerResult = normalizeAndroidInstallReferrerResult;
|
|
11
12
|
exports.normalizeDeferredHandoffToken = normalizeDeferredHandoffToken;
|
|
12
13
|
/**
|
|
@@ -18,6 +19,14 @@ exports.normalizeDeferredHandoffToken = normalizeDeferredHandoffToken;
|
|
|
18
19
|
* optional account bridge below.
|
|
19
20
|
*/
|
|
20
21
|
|
|
22
|
+
/** Explicit receiver decision for one terminal-delivery attempt. */
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Sanitized terminal result of the anonymous first-open rail. It deliberately carries no
|
|
26
|
+
* install-attempt id, token, device signal or raw server error. A 204 is always `no_route` and
|
|
27
|
+
* can never make the client navigate, including when Encore reports a shadow observation.
|
|
28
|
+
*/
|
|
29
|
+
|
|
21
30
|
/** Implemented by the separate native `PulseAttribution` module. */
|
|
22
31
|
|
|
23
32
|
const OPAQUE_TOKEN = /^[A-Za-z0-9_-]{16,512}$/;
|
|
@@ -34,6 +43,14 @@ const STATUSES = new Set(['idle', 'pending', 'resolving', 'waiting_for_account',
|
|
|
34
43
|
const INSTALL_REFERRER_STATUSES = new Set(['OK', 'NO_TOKEN', 'FEATURE_NOT_SUPPORTED', 'SERVICE_UNAVAILABLE', 'DEVELOPER_ERROR', 'SERVICE_DISCONNECTED']);
|
|
35
44
|
const DEFAULT_ACTIONS = ['open_home', 'open_premium', 'manage_subscription', 'refresh_entitlement'];
|
|
36
45
|
const DEFAULT_SENSITIVE_ACTIONS = ['manage_subscription', 'refresh_entitlement'];
|
|
46
|
+
const PROBABILISTIC_ACTIONS = new Set(['open_home', 'open_premium']);
|
|
47
|
+
const CAPTURE_PRIORITY = {
|
|
48
|
+
direct_token: 3,
|
|
49
|
+
ios_user_paste: 2,
|
|
50
|
+
android_install_referrer: 1
|
|
51
|
+
};
|
|
52
|
+
const NO_ROUTE_REASONS = new Set(['unmatched', 'ambiguous', 'holdout', 'low_confidence', 'shadow_would_route', 'shadow_attributed', 'analytics_attributed', 'target_revoked', 'disabled', 'expired_replay']);
|
|
53
|
+
const FAILURE_REASONS = new Set(['invalid_request', 'invalid_response', 'policy_rejected']);
|
|
37
54
|
const DEFAULT_STORAGE_KEY = 'pulse.links.v1';
|
|
38
55
|
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
39
56
|
const DEFAULT_RETRY_BASE_MS = 15_000;
|
|
@@ -44,10 +61,14 @@ const MAX_APPLIED_IDS = 32;
|
|
|
44
61
|
const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
|
|
45
62
|
const MAX_RESOLVER_OUTCOMES = MAX_NOTIFIED_OUTCOMES;
|
|
46
63
|
const MAX_PERSISTED_BYTES = 131_072;
|
|
64
|
+
const ASYNCHRONOUS_STORAGE_ADAPTERS = new WeakSet();
|
|
47
65
|
const VERSION_SIGNAL = /^[A-Za-z0-9][A-Za-z0-9._+()-]*$/;
|
|
48
66
|
const DEVICE_MODEL_CODE = /^[A-Za-z0-9][A-Za-z0-9._,+-]*$/;
|
|
49
67
|
const DISTRIBUTION_SIGNAL = /^[a-z0-9][a-z0-9._-]*$/;
|
|
50
68
|
const DEVICE_TYPES = new Set(['phone', 'tablet', 'tv', 'desktop', 'gaming_console', 'unknown']);
|
|
69
|
+
|
|
70
|
+
/** Minimal sticky privacy tombstone. It carries no terminal outcome or attribution metadata. */
|
|
71
|
+
|
|
51
72
|
const emptyState = () => ({
|
|
52
73
|
version: 1,
|
|
53
74
|
status: 'idle',
|
|
@@ -56,6 +77,7 @@ const emptyState = () => ({
|
|
|
56
77
|
appliedIds: [],
|
|
57
78
|
notifiedOutcomes: [],
|
|
58
79
|
outcomeQueue: [],
|
|
80
|
+
terminalDelivery: null,
|
|
59
81
|
firstOpen: {
|
|
60
82
|
installAttemptId: null,
|
|
61
83
|
completed: false,
|
|
@@ -156,6 +178,7 @@ function createPulseLinkClient(options) {
|
|
|
156
178
|
}
|
|
157
179
|
const storageKey = explicitStorageKey || (appSlug ? `pulse.${appSlug}.links.v1` : DEFAULT_STORAGE_KEY);
|
|
158
180
|
const requestTimeoutMs = boundedDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 500, 60_000);
|
|
181
|
+
const terminalDeliveryTimeoutMs = boundedDuration(options.terminalDeliveryTimeoutMs, requestTimeoutMs, 100, 60_000);
|
|
159
182
|
const retryBaseMs = boundedDuration(options.retryBaseMs, DEFAULT_RETRY_BASE_MS, 100, 60 * 60 * 1_000);
|
|
160
183
|
const retryMaxMs = boundedDuration(options.retryMaxMs, DEFAULT_RETRY_MAX_MS, retryBaseMs, 24 * 60 * 60 * 1_000);
|
|
161
184
|
const tokenMaxAgeMs = boundedDuration(options.tokenMaxAgeMs, DEFAULT_TOKEN_MAX_AGE_MS, 60_000, 365 * 24 * 60 * 60 * 1_000);
|
|
@@ -164,11 +187,26 @@ function createPulseLinkClient(options) {
|
|
|
164
187
|
let state = readState(options.storage, storageKey);
|
|
165
188
|
let disposed = false;
|
|
166
189
|
let processing = null;
|
|
190
|
+
let firstOpenMatching = null;
|
|
167
191
|
let processRequested = false;
|
|
168
192
|
let retryTimer = null;
|
|
169
193
|
let outcomeSending = null;
|
|
194
|
+
let terminalDeliverySending = null;
|
|
195
|
+
let terminalDeliveryGeneration = 0;
|
|
170
196
|
let accountRetryAt = 0;
|
|
171
197
|
let accountAttempts = 0;
|
|
198
|
+
let deterministicEpoch = 0;
|
|
199
|
+
// Invalidates every host callback that crossed an await when this client is reset/disposed.
|
|
200
|
+
// This is deliberately separate from deterministicEpoch: captures during a committed callback
|
|
201
|
+
// have intentional last-touch semantics, whereas a lifecycle boundary must forbid every stale
|
|
202
|
+
// state write, retry and outcome from the old instance.
|
|
203
|
+
let lifecycleGeneration = 0;
|
|
204
|
+
// Host navigation is irreversible once any routing callback has started: it may perform its
|
|
205
|
+
// side effect synchronously before returning a Promise. Serialize captures against that commit
|
|
206
|
+
// boundary and keep the newer accepted token durable for the next client lifecycle instead of
|
|
207
|
+
// automatically opening a second journey behind the first one.
|
|
208
|
+
let routingApplicationInFlight = false;
|
|
209
|
+
let deterministicTokenDeferredAfterCommittedJourney = null;
|
|
172
210
|
let lastFirstOpenContext = null;
|
|
173
211
|
let unsubscribeAccount = null;
|
|
174
212
|
const listeners = new Set();
|
|
@@ -177,6 +215,25 @@ function createPulseLinkClient(options) {
|
|
|
177
215
|
options.onError?.(error);
|
|
178
216
|
} catch {/* diagnostic hooks never break link handling */}
|
|
179
217
|
};
|
|
218
|
+
|
|
219
|
+
// A build without a receiver has not opted into retaining this analytics envelope. First replace
|
|
220
|
+
// an old full record with the same minimal sticky tombstone used by an explicit drop. Deletion
|
|
221
|
+
// may fail, but a later app version can then only retry local deletion, never resurrect delivery.
|
|
222
|
+
if (!options.onFirstOpenResult && state.terminalDelivery && state.terminalDelivery.disposition !== 'drop') {
|
|
223
|
+
const tombstone = {
|
|
224
|
+
eventId: state.terminalDelivery.eventId,
|
|
225
|
+
disposition: 'drop',
|
|
226
|
+
attempts: state.terminalDelivery.attempts,
|
|
227
|
+
nextRetryAt: 0
|
|
228
|
+
};
|
|
229
|
+
state = {
|
|
230
|
+
...state,
|
|
231
|
+
terminalDelivery: tombstone
|
|
232
|
+
};
|
|
233
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
234
|
+
reportError(new Error('Pulse Links: stale terminal delivery tombstone was not persisted'));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
180
237
|
const snapshot = () => ({
|
|
181
238
|
status: state.status,
|
|
182
239
|
pending: state.pending ? {
|
|
@@ -188,8 +245,7 @@ function createPulseLinkClient(options) {
|
|
|
188
245
|
...state.firstOpen
|
|
189
246
|
}
|
|
190
247
|
});
|
|
191
|
-
const
|
|
192
|
-
writeState(options.storage, storageKey, state);
|
|
248
|
+
const notifyState = () => {
|
|
193
249
|
const value = snapshot();
|
|
194
250
|
for (const listener of listeners) {
|
|
195
251
|
try {
|
|
@@ -205,6 +261,11 @@ function createPulseLinkClient(options) {
|
|
|
205
261
|
}
|
|
206
262
|
scheduleWake();
|
|
207
263
|
};
|
|
264
|
+
const persistAndNotify = () => {
|
|
265
|
+
const persisted = writeState(options.storage, storageKey, state);
|
|
266
|
+
notifyState();
|
|
267
|
+
return persisted;
|
|
268
|
+
};
|
|
208
269
|
const patchState = patch => {
|
|
209
270
|
state = {
|
|
210
271
|
...state,
|
|
@@ -222,13 +283,229 @@ function createPulseLinkClient(options) {
|
|
|
222
283
|
};
|
|
223
284
|
persistAndNotify();
|
|
224
285
|
};
|
|
225
|
-
const
|
|
286
|
+
const terminalDeliveryOutcome = queued => ({
|
|
287
|
+
eventId: queued.eventId,
|
|
288
|
+
status: queued.status,
|
|
289
|
+
rail: queued.rail,
|
|
290
|
+
routed: queued.routed,
|
|
291
|
+
retryable: false,
|
|
292
|
+
reason: queued.reason,
|
|
293
|
+
occurredAt: queued.occurredAt,
|
|
294
|
+
...(queued.matchBasis !== undefined ? {
|
|
295
|
+
matchBasis: queued.matchBasis
|
|
296
|
+
} : {}),
|
|
297
|
+
...(queued.confidence !== undefined ? {
|
|
298
|
+
confidence: queued.confidence
|
|
299
|
+
} : {}),
|
|
300
|
+
...(queued.campaignId !== undefined ? {
|
|
301
|
+
campaignId: queued.campaignId
|
|
302
|
+
} : {}),
|
|
303
|
+
...(queued.experimentId !== undefined ? {
|
|
304
|
+
experimentId: queued.experimentId
|
|
305
|
+
} : {}),
|
|
306
|
+
...(queued.variantId !== undefined ? {
|
|
307
|
+
variantId: queued.variantId
|
|
308
|
+
} : {})
|
|
309
|
+
});
|
|
310
|
+
const scheduleTerminalDeliveryRetry = queued => {
|
|
311
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return;
|
|
312
|
+
const attempts = Math.min(queued.attempts + 1, 100_000);
|
|
313
|
+
state = {
|
|
314
|
+
...state,
|
|
315
|
+
terminalDelivery: {
|
|
316
|
+
...queued,
|
|
317
|
+
attempts,
|
|
318
|
+
nextRetryAt: now() + resolverOutcomeRetryDelay(queued.eventId, attempts, retryBaseMs, retryMaxMs)
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
persistAndNotify();
|
|
322
|
+
};
|
|
323
|
+
const acknowledgeTerminalDelivery = queued => {
|
|
324
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
325
|
+
const acknowledgedState = {
|
|
326
|
+
...state,
|
|
327
|
+
terminalDelivery: null
|
|
328
|
+
};
|
|
329
|
+
// Clearing the record is itself transactional. If this write fails after the receiver
|
|
330
|
+
// accepted the event, retain and replay the same eventId: that is why the contract is
|
|
331
|
+
// at-least-once and why receivers must deduplicate.
|
|
332
|
+
if (!writeState(options.storage, storageKey, acknowledgedState, false)) {
|
|
333
|
+
reportError(new Error('Pulse Links: terminal delivery acknowledgement was not persisted'));
|
|
334
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
state = acknowledgedState;
|
|
338
|
+
notifyState();
|
|
339
|
+
return true;
|
|
340
|
+
};
|
|
341
|
+
const flushTerminalDelivery = () => {
|
|
342
|
+
if (disposed) return Promise.resolve(false);
|
|
343
|
+
if (terminalDeliverySending) return terminalDeliverySending;
|
|
344
|
+
const queued = state.terminalDelivery;
|
|
345
|
+
if (!queued || queued.nextRetryAt > now()) return Promise.resolve(false);
|
|
346
|
+
if (queued.disposition === 'drop') {
|
|
347
|
+
return Promise.resolve(acknowledgeTerminalDelivery(queued));
|
|
348
|
+
}
|
|
349
|
+
if (!options.onFirstOpenResult) return Promise.resolve(false);
|
|
350
|
+
const generation = terminalDeliveryGeneration;
|
|
351
|
+
const run = async () => {
|
|
352
|
+
// Never call the receiver before the complete record is durable. With no storage adapter,
|
|
353
|
+
// this is an explicitly memory-only degradation rather than a cross-restart guarantee.
|
|
354
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
355
|
+
reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
|
|
356
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
let rawDisposition;
|
|
360
|
+
try {
|
|
361
|
+
rawDisposition = options.onFirstOpenResult(terminalDeliveryOutcome(queued));
|
|
362
|
+
} catch (error) {
|
|
363
|
+
reportError(error);
|
|
364
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
let disposition;
|
|
368
|
+
if (typeof rawDisposition === 'boolean' || typeof rawDisposition === 'string') {
|
|
369
|
+
disposition = normalizeTerminalDeliveryDisposition(rawDisposition);
|
|
370
|
+
} else {
|
|
371
|
+
try {
|
|
372
|
+
const resolved = await withTerminalDeliveryTimeout(Promise.resolve(rawDisposition), terminalDeliveryTimeoutMs);
|
|
373
|
+
disposition = normalizeTerminalDeliveryDisposition(resolved);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if (!disposed && terminalDeliveryGeneration === generation) reportError(error);
|
|
376
|
+
if (!disposed && terminalDeliveryGeneration === generation) {
|
|
377
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
378
|
+
}
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (disposed || terminalDeliveryGeneration !== generation || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
383
|
+
if (disposition === 'accepted') return acknowledgeTerminalDelivery(queued);
|
|
384
|
+
if (disposition === 'drop') return dropTerminalDelivery(queued);
|
|
385
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
386
|
+
return false;
|
|
387
|
+
};
|
|
388
|
+
let owned;
|
|
389
|
+
owned = run().finally(() => {
|
|
390
|
+
if (terminalDeliverySending === owned) terminalDeliverySending = null;
|
|
391
|
+
const pending = state.terminalDelivery;
|
|
392
|
+
if (!disposed && options.onFirstOpenResult && pending && pending.nextRetryAt <= now()) {
|
|
393
|
+
void flushTerminalDelivery();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
terminalDeliverySending = owned;
|
|
397
|
+
return owned;
|
|
398
|
+
};
|
|
399
|
+
const dropTerminalDelivery = queued => {
|
|
400
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
401
|
+
const tombstone = {
|
|
402
|
+
eventId: queued.eventId,
|
|
403
|
+
disposition: 'drop',
|
|
404
|
+
attempts: queued.attempts,
|
|
405
|
+
nextRetryAt: 0
|
|
406
|
+
};
|
|
407
|
+
state = {
|
|
408
|
+
...state,
|
|
409
|
+
terminalDelivery: tombstone
|
|
410
|
+
};
|
|
411
|
+
// Persist the sticky disposition before trying to delete it. A failed local deletion can then
|
|
412
|
+
// retry only deletion after restart; it must never call the receiver or resurrect collection.
|
|
413
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
414
|
+
reportError(new Error('Pulse Links: terminal drop tombstone was not persisted'));
|
|
415
|
+
scheduleTerminalDeliveryRetry(tombstone);
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
notifyState();
|
|
419
|
+
return acknowledgeTerminalDelivery(tombstone);
|
|
420
|
+
};
|
|
421
|
+
const completeFirstOpen = (status, outcome) => {
|
|
422
|
+
const completionLifecycleGeneration = lifecycleGeneration;
|
|
423
|
+
const completionDeterministicEpoch = deterministicEpoch;
|
|
424
|
+
const completionIsCurrent = () => !disposed && lifecycleGeneration === completionLifecycleGeneration && deterministicEpoch === completionDeterministicEpoch;
|
|
425
|
+
let shouldQueue = Boolean(options.onFirstOpenResult);
|
|
426
|
+
if (shouldQueue && options.shouldQueueFirstOpenResult) {
|
|
427
|
+
try {
|
|
428
|
+
shouldQueue = options.shouldQueueFirstOpenResult() === true;
|
|
429
|
+
} catch (error) {
|
|
430
|
+
shouldQueue = false;
|
|
431
|
+
reportError(error);
|
|
432
|
+
}
|
|
433
|
+
// Privacy/diagnostic hooks are host code and may synchronously reset, dispose, or capture a
|
|
434
|
+
// deterministic destination. Never resurrect the terminal first-open state they invalidated.
|
|
435
|
+
if (!completionIsCurrent()) return;
|
|
436
|
+
}
|
|
437
|
+
if (!shouldQueue) {
|
|
438
|
+
state = {
|
|
439
|
+
...state,
|
|
440
|
+
status,
|
|
441
|
+
terminalDelivery: null,
|
|
442
|
+
firstOpen: {
|
|
443
|
+
...state.firstOpen,
|
|
444
|
+
completed: true,
|
|
445
|
+
attempts: 0,
|
|
446
|
+
nextRetryAt: 0
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
persistAndNotify();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const existingEventIds = new Set([...state.outcomeQueue.map(queued => queued.eventId), ...(state.firstOpen.installAttemptId ? [state.firstOpen.installAttemptId] : []), ...(state.terminalDelivery ? [state.terminalDelivery.eventId] : [])]);
|
|
453
|
+
const delivery = state.terminalDelivery ?? {
|
|
454
|
+
...outcome,
|
|
455
|
+
eventId: makeOutcomeEventId(options.randomUUID, existingEventIds),
|
|
456
|
+
retryable: false,
|
|
457
|
+
occurredAt: new Date(now()).toISOString(),
|
|
458
|
+
attempts: 0,
|
|
459
|
+
nextRetryAt: 0
|
|
460
|
+
};
|
|
461
|
+
state = {
|
|
462
|
+
...state,
|
|
463
|
+
status,
|
|
464
|
+
terminalDelivery: delivery,
|
|
465
|
+
firstOpen: {
|
|
466
|
+
...state.firstOpen,
|
|
467
|
+
completed: true,
|
|
468
|
+
attempts: 0,
|
|
469
|
+
nextRetryAt: 0
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
if (!persistAndNotify()) {
|
|
473
|
+
reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
|
|
474
|
+
}
|
|
475
|
+
// Delivery is deliberately detached from routing/matcher completion.
|
|
476
|
+
void flushTerminalDelivery();
|
|
477
|
+
};
|
|
478
|
+
const blockFirstOpenForDeterministic = () => {
|
|
479
|
+
const blockingLifecycleGeneration = lifecycleGeneration;
|
|
480
|
+
deterministicEpoch += 1;
|
|
481
|
+
const blockingDeterministicEpoch = deterministicEpoch;
|
|
482
|
+
if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return true;
|
|
483
|
+
state = {
|
|
484
|
+
...state,
|
|
485
|
+
firstOpen: {
|
|
486
|
+
...state.firstOpen,
|
|
487
|
+
completed: true,
|
|
488
|
+
nextRetryAt: 0
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
persistAndNotify();
|
|
492
|
+
return !disposed && lifecycleGeneration === blockingLifecycleGeneration && deterministicEpoch === blockingDeterministicEpoch;
|
|
493
|
+
};
|
|
494
|
+
const readAccountReadiness = () => {
|
|
495
|
+
const readinessLifecycleGeneration = lifecycleGeneration;
|
|
496
|
+
const readinessDeterministicEpoch = deterministicEpoch;
|
|
497
|
+
let ready = false;
|
|
226
498
|
try {
|
|
227
|
-
|
|
499
|
+
ready = options.isAccountReady?.() ?? false;
|
|
228
500
|
} catch (error) {
|
|
229
501
|
reportError(error);
|
|
230
|
-
return false;
|
|
231
502
|
}
|
|
503
|
+
return {
|
|
504
|
+
ready,
|
|
505
|
+
// Readiness is host code, not a pure getter. A reset/dispose/capture inside it invalidates
|
|
506
|
+
// the caller's snapshot and must be observed before any waiting/retry/application write.
|
|
507
|
+
current: !disposed && lifecycleGeneration === readinessLifecycleGeneration && deterministicEpoch === readinessDeterministicEpoch
|
|
508
|
+
};
|
|
232
509
|
};
|
|
233
510
|
const schedulePendingRetry = () => {
|
|
234
511
|
const pending = state.pending;
|
|
@@ -436,7 +713,9 @@ function createPulseLinkClient(options) {
|
|
|
436
713
|
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
437
714
|
return false;
|
|
438
715
|
}
|
|
439
|
-
|
|
716
|
+
const accountReadiness = readAccountReadiness();
|
|
717
|
+
if (!accountReadiness.current) return false;
|
|
718
|
+
if (!accountReadiness.ready) {
|
|
440
719
|
patchState({
|
|
441
720
|
status: 'waiting_for_account'
|
|
442
721
|
});
|
|
@@ -454,9 +733,34 @@ function createPulseLinkClient(options) {
|
|
|
454
733
|
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
455
734
|
return false;
|
|
456
735
|
}
|
|
736
|
+
let applied = false;
|
|
737
|
+
let applicationFailed = false;
|
|
738
|
+
let applicationError;
|
|
739
|
+
const applicationEpoch = deterministicEpoch;
|
|
740
|
+
const applicationLifecycleGeneration = lifecycleGeneration;
|
|
741
|
+
const lifecycleIsCurrent = () => !disposed && lifecycleGeneration === applicationLifecycleGeneration;
|
|
742
|
+
const hasNewerPendingIntent = () => deterministicEpoch !== applicationEpoch && state.pending !== null
|
|
743
|
+
// A provenance upgrade or rejected lower-priority capture for the token already being
|
|
744
|
+
// applied does not represent another destination and must not leave that token pending.
|
|
745
|
+
&& (sourceToken === null || state.pending.token !== sourceToken);
|
|
746
|
+
routingApplicationInFlight = true;
|
|
457
747
|
emitOutcome(link, 'app_open_confirmed');
|
|
748
|
+
if (!lifecycleIsCurrent()) {
|
|
749
|
+
routingApplicationInFlight = false;
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
458
752
|
emitOutcome(link, 'deferred_link_resolved');
|
|
459
|
-
|
|
753
|
+
if (!lifecycleIsCurrent()) {
|
|
754
|
+
routingApplicationInFlight = false;
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
// `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
|
|
758
|
+
// a newer accepted destination, it still arrived before the routing callback and must win.
|
|
759
|
+
if (hasNewerPendingIntent()) {
|
|
760
|
+
routingApplicationInFlight = false;
|
|
761
|
+
void process();
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
460
764
|
try {
|
|
461
765
|
if (link.deepLink) {
|
|
462
766
|
applied = await options.onDeepLink(link.deepLink, link);
|
|
@@ -467,18 +771,50 @@ function createPulseLinkClient(options) {
|
|
|
467
771
|
return false;
|
|
468
772
|
}
|
|
469
773
|
} catch (error) {
|
|
470
|
-
|
|
774
|
+
applicationFailed = true;
|
|
775
|
+
applicationError = error;
|
|
776
|
+
} finally {
|
|
777
|
+
routingApplicationInFlight = false;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// The callback may resolve after reset/dispose and after another client has already committed
|
|
781
|
+
// a newer journey into the same storage. The old instance must not schedule a retry, rewrite
|
|
782
|
+
// state, or emit action_applied regardless of whether the callback returned true/false/threw.
|
|
783
|
+
if (!lifecycleIsCurrent()) return false;
|
|
784
|
+
const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
|
|
785
|
+
if (applicationFailed) {
|
|
786
|
+
reportError(applicationError);
|
|
787
|
+
if (!lifecycleIsCurrent()) return false;
|
|
788
|
+
// onError is host code too: it may capture a newer deterministic destination while reporting
|
|
789
|
+
// this failure. Re-evaluate after the callback instead of arming a stale retry from the value
|
|
790
|
+
// observed before diagnostics ran.
|
|
791
|
+
if (hasNewerPendingIntent()) {
|
|
792
|
+
// The current action did not commit. Resume the newer accepted intent that was captured
|
|
793
|
+
// while its callback was suspended, without scheduling a retry for the superseded one.
|
|
794
|
+
void process();
|
|
795
|
+
return false;
|
|
796
|
+
}
|
|
471
797
|
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
|
|
472
798
|
return false;
|
|
473
799
|
}
|
|
474
800
|
if (applied === false) {
|
|
801
|
+
if (newerIntentArrivedDuringApplication) {
|
|
802
|
+
void process();
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
475
805
|
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
|
|
476
806
|
return false;
|
|
477
807
|
}
|
|
808
|
+
if (newerIntentArrivedDuringApplication) {
|
|
809
|
+
// Invocation of any host routing callback is the last safe commit boundary. A successful
|
|
810
|
+
// callback may already have navigated, so automatically applying the newly captured token
|
|
811
|
+
// now would create two journeys. Keep it pending and let a fresh SDK lifecycle recover it.
|
|
812
|
+
deterministicTokenDeferredAfterCommittedJourney = state.pending.token;
|
|
813
|
+
}
|
|
478
814
|
const newlyApplied = sourceToken && sourceToken !== link.id ? [link.id, sourceToken] : [link.id];
|
|
479
815
|
const newlyAppliedSet = new Set(newlyApplied);
|
|
480
816
|
const appliedIds = [...state.appliedIds.filter(id => !newlyAppliedSet.has(id)), ...newlyApplied].slice(-MAX_APPLIED_IDS);
|
|
481
|
-
const stillPending = sourceToken !== null
|
|
817
|
+
const stillPending = sourceToken !== null ? state.pending?.token !== sourceToken : newerIntentArrivedDuringApplication;
|
|
482
818
|
state = {
|
|
483
819
|
...state,
|
|
484
820
|
status: stillPending ? 'pending' : 'applied',
|
|
@@ -487,12 +823,16 @@ function createPulseLinkClient(options) {
|
|
|
487
823
|
appliedIds
|
|
488
824
|
};
|
|
489
825
|
persistAndNotify();
|
|
826
|
+
if (!lifecycleIsCurrent()) return false;
|
|
490
827
|
if (link.matchBasis === 'account_bound') {
|
|
491
828
|
accountAttempts = 0;
|
|
492
829
|
accountRetryAt = 0;
|
|
493
830
|
}
|
|
494
831
|
emitOutcome(link, 'action_applied');
|
|
495
|
-
if (
|
|
832
|
+
if (!lifecycleIsCurrent()) return false;
|
|
833
|
+
if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
|
|
834
|
+
void process();
|
|
835
|
+
}
|
|
496
836
|
return true;
|
|
497
837
|
};
|
|
498
838
|
const resolvePublic = async (token, basis) => {
|
|
@@ -529,19 +869,26 @@ function createPulseLinkClient(options) {
|
|
|
529
869
|
};
|
|
530
870
|
const processOnce = async () => {
|
|
531
871
|
if (disposed) return;
|
|
872
|
+
// `capture()` can re-enter while any host callback is awaiting. Never start another routing
|
|
873
|
+
// application concurrently, and never auto-drain the exact token preserved after an already
|
|
874
|
+
// committed journey in this client lifecycle.
|
|
875
|
+
if (routingApplicationInFlight) return;
|
|
532
876
|
const pending = state.pending;
|
|
533
877
|
if (pending) {
|
|
878
|
+
if (pending.token === deterministicTokenDeferredAfterCommittedJourney) return;
|
|
534
879
|
if (pending.nextRetryAt > now()) return;
|
|
535
880
|
if (now() - pending.receivedAt > tokenMaxAgeMs) {
|
|
536
881
|
clearPendingAsTerminal();
|
|
537
882
|
return;
|
|
538
883
|
}
|
|
539
884
|
const token = pending.token;
|
|
885
|
+
const pendingLifecycleGeneration = lifecycleGeneration;
|
|
540
886
|
patchState({
|
|
541
887
|
status: 'resolving'
|
|
542
888
|
});
|
|
889
|
+
if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
543
890
|
const publicResult = await resolvePublic(token, pending.matchBasis);
|
|
544
|
-
if (disposed || state.pending?.token !== token) return;
|
|
891
|
+
if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
545
892
|
if (publicResult.kind === 'resolved') {
|
|
546
893
|
await applyResolved(publicResult.link, token);
|
|
547
894
|
return;
|
|
@@ -550,15 +897,23 @@ function createPulseLinkClient(options) {
|
|
|
550
897
|
schedulePendingRetry();
|
|
551
898
|
return;
|
|
552
899
|
}
|
|
553
|
-
if (!options.accountBridge
|
|
900
|
+
if (!options.accountBridge) {
|
|
901
|
+
patchState({
|
|
902
|
+
status: 'waiting_for_account'
|
|
903
|
+
});
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
const accountReadiness = readAccountReadiness();
|
|
907
|
+
if (!accountReadiness.current || disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
908
|
+
if (!accountReadiness.ready) {
|
|
554
909
|
patchState({
|
|
555
910
|
status: 'waiting_for_account'
|
|
556
911
|
});
|
|
557
912
|
return;
|
|
558
913
|
}
|
|
559
914
|
try {
|
|
560
|
-
const raw = await options.accountBridge.claim(token);
|
|
561
|
-
if (disposed || state.pending?.token !== token) return;
|
|
915
|
+
const raw = await withPromiseTimeout(() => options.accountBridge.claim(token), requestTimeoutMs, 'account claim');
|
|
916
|
+
if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
562
917
|
const normalizedClaim = normalizeResolved(raw, token, 'account_bound', allowedActions, now());
|
|
563
918
|
const claimed = normalizedClaim ? {
|
|
564
919
|
...normalizedClaim,
|
|
@@ -566,16 +921,29 @@ function createPulseLinkClient(options) {
|
|
|
566
921
|
matchGuaranteed: true,
|
|
567
922
|
confidence: 1
|
|
568
923
|
} : null;
|
|
569
|
-
if (claimed)
|
|
924
|
+
if (claimed) {
|
|
925
|
+
if (!blockFirstOpenForDeterministic()) return;
|
|
926
|
+
await applyResolved(claimed, token);
|
|
927
|
+
} else clearPendingAsTerminal();
|
|
570
928
|
} catch (error) {
|
|
929
|
+
if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
571
930
|
reportError(error);
|
|
572
|
-
if (state.pending?.token === token) schedulePendingRetry();
|
|
931
|
+
if (!disposed && lifecycleGeneration === pendingLifecycleGeneration && state.pending?.token === token) schedulePendingRetry();
|
|
573
932
|
}
|
|
574
933
|
return;
|
|
575
934
|
}
|
|
576
|
-
if (!options.accountBridge
|
|
935
|
+
if (!options.accountBridge) return;
|
|
936
|
+
const accountReadiness = readAccountReadiness();
|
|
937
|
+
if (!accountReadiness.current || state.pending) return;
|
|
938
|
+
if (!accountReadiness.ready || accountRetryAt > now()) return;
|
|
939
|
+
const accountPendingEpoch = deterministicEpoch;
|
|
940
|
+
const accountPendingLifecycleGeneration = lifecycleGeneration;
|
|
577
941
|
try {
|
|
578
|
-
const raw = await options.accountBridge.pending();
|
|
942
|
+
const raw = await withPromiseTimeout(() => options.accountBridge.pending(), requestTimeoutMs, 'account pending');
|
|
943
|
+
// A URL/paste/referrer captured while the account lookup was in flight is newer explicit
|
|
944
|
+
// user intent. Ignore the stale bridge response and let the requested drain resolve the
|
|
945
|
+
// captured token; otherwise both deterministic destinations could be applied.
|
|
946
|
+
if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
579
947
|
if (!raw) {
|
|
580
948
|
accountAttempts = 0;
|
|
581
949
|
accountRetryAt = 0;
|
|
@@ -594,21 +962,31 @@ function createPulseLinkClient(options) {
|
|
|
594
962
|
matchGuaranteed: true,
|
|
595
963
|
confidence: 1
|
|
596
964
|
} : null;
|
|
597
|
-
if (pendingLink
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
965
|
+
if (pendingLink) {
|
|
966
|
+
// Reserve the first-open journey for deterministic recovery before invoking any host
|
|
967
|
+
// callback. This also covers an already-applied account item restored from older SDK
|
|
968
|
+
// state, which must still prevent a second probabilistic destination.
|
|
969
|
+
if (!blockFirstOpenForDeterministic()) return;
|
|
970
|
+
if (!state.appliedIds.includes(pendingLink.id)) {
|
|
971
|
+
await applyResolved(pendingLink, null);
|
|
972
|
+
} else if (state.status !== 'applied') {
|
|
973
|
+
patchState({
|
|
974
|
+
status: 'applied'
|
|
975
|
+
});
|
|
976
|
+
}
|
|
603
977
|
}
|
|
604
978
|
} catch (error) {
|
|
979
|
+
if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
605
980
|
reportError(error);
|
|
981
|
+
if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
606
982
|
// No token was consumed; the authenticated server outbox remains authoritative.
|
|
607
983
|
scheduleAccountRetry();
|
|
608
984
|
}
|
|
609
985
|
};
|
|
610
986
|
const process = async () => {
|
|
611
987
|
if (disposed) return;
|
|
988
|
+
// Application delivery is independent: never await it on the routing drain.
|
|
989
|
+
void flushTerminalDelivery();
|
|
612
990
|
processRequested = true;
|
|
613
991
|
if (processing) return processing;
|
|
614
992
|
const run = (async () => {
|
|
@@ -632,6 +1010,7 @@ function createPulseLinkClient(options) {
|
|
|
632
1010
|
const token = normalizeDeferredHandoffToken(rawToken);
|
|
633
1011
|
if (!token) return false;
|
|
634
1012
|
if (state.appliedIds.includes(token)) {
|
|
1013
|
+
deterministicEpoch += 1;
|
|
635
1014
|
if (!state.firstOpen.completed) patchFirstOpen({
|
|
636
1015
|
completed: true,
|
|
637
1016
|
nextRetryAt: 0
|
|
@@ -639,7 +1018,8 @@ function createPulseLinkClient(options) {
|
|
|
639
1018
|
return true;
|
|
640
1019
|
}
|
|
641
1020
|
if (state.pending?.token === token) {
|
|
642
|
-
|
|
1021
|
+
deterministicEpoch += 1;
|
|
1022
|
+
if (CAPTURE_PRIORITY[basis] > CAPTURE_PRIORITY[state.pending.matchBasis]) {
|
|
643
1023
|
state = {
|
|
644
1024
|
...state,
|
|
645
1025
|
pending: {
|
|
@@ -662,6 +1042,20 @@ function createPulseLinkClient(options) {
|
|
|
662
1042
|
void process();
|
|
663
1043
|
return true;
|
|
664
1044
|
}
|
|
1045
|
+
|
|
1046
|
+
// Exact transports are deterministic, but when more than one arrives during cold start their
|
|
1047
|
+
// provenance still has an explicit precedence. A lower-priority late callback must not replace
|
|
1048
|
+
// the URL the user intentionally opened; equal priority keeps last-touch behaviour.
|
|
1049
|
+
if (state.pending && CAPTURE_PRIORITY[basis] < CAPTURE_PRIORITY[state.pending.matchBasis]) {
|
|
1050
|
+
deterministicEpoch += 1;
|
|
1051
|
+
if (!state.firstOpen.completed) patchFirstOpen({
|
|
1052
|
+
completed: true,
|
|
1053
|
+
nextRetryAt: 0
|
|
1054
|
+
});
|
|
1055
|
+
void process();
|
|
1056
|
+
return true;
|
|
1057
|
+
}
|
|
1058
|
+
deterministicEpoch += 1;
|
|
665
1059
|
state = {
|
|
666
1060
|
...state,
|
|
667
1061
|
status: 'pending',
|
|
@@ -682,14 +1076,41 @@ function createPulseLinkClient(options) {
|
|
|
682
1076
|
void process();
|
|
683
1077
|
return true;
|
|
684
1078
|
};
|
|
685
|
-
const
|
|
1079
|
+
const matchFirstOpenOnce = async context => {
|
|
686
1080
|
if (disposed) return 'ineligible';
|
|
687
1081
|
lastFirstOpenContext = context;
|
|
688
1082
|
if (state.pending) return 'deterministic_pending';
|
|
689
1083
|
if (state.firstOpen.completed) return 'already_completed';
|
|
690
|
-
if (state.firstOpen.nextRetryAt > now())
|
|
1084
|
+
if (state.firstOpen.nextRetryAt > now()) {
|
|
1085
|
+
// On a fresh client lifecycle scheduleWake() ran before the host supplied this ephemeral
|
|
1086
|
+
// context, so the persisted first-open deadline could not be part of its candidates. Re-arm
|
|
1087
|
+
// now that retrying is possible; otherwise a 503 followed by process death remains asleep
|
|
1088
|
+
// until some unrelated foreground/manual call invokes matchFirstOpen again.
|
|
1089
|
+
scheduleWake();
|
|
1090
|
+
return 'backoff';
|
|
1091
|
+
}
|
|
1092
|
+
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
1093
|
+
|
|
1094
|
+
// The client starts account recovery at construction. Join that deterministic rail before
|
|
1095
|
+
// asking the probabilistic matcher; otherwise a slow Billing/account response can navigate
|
|
1096
|
+
// after the probabilistic destination and the user observes both journeys.
|
|
1097
|
+
await process();
|
|
1098
|
+
if (disposed) return 'ineligible';
|
|
1099
|
+
if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1100
|
+
if (options.accountBridge) {
|
|
1101
|
+
const accountReadiness = readAccountReadiness();
|
|
1102
|
+
if (!accountReadiness.current) {
|
|
1103
|
+
return disposed ? 'ineligible' : 'deterministic_pending';
|
|
1104
|
+
}
|
|
1105
|
+
if (accountReadiness.ready && accountRetryAt > now()) {
|
|
1106
|
+
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
1107
|
+
return 'retry_scheduled';
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
691
1110
|
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
692
1111
|
const installAttemptId = state.firstOpen.installAttemptId ?? makeInstallAttemptId(options.randomUUID);
|
|
1112
|
+
const startingDeterministicEpoch = deterministicEpoch;
|
|
1113
|
+
const startingLifecycleGeneration = lifecycleGeneration;
|
|
693
1114
|
state = {
|
|
694
1115
|
...state,
|
|
695
1116
|
status: 'resolving',
|
|
@@ -699,14 +1120,44 @@ function createPulseLinkClient(options) {
|
|
|
699
1120
|
attemptedAt: now()
|
|
700
1121
|
}
|
|
701
1122
|
};
|
|
702
|
-
persistAndNotify();
|
|
1123
|
+
const firstOpenReservationPersisted = persistAndNotify();
|
|
1124
|
+
// Persistence notifies host state hooks synchronously. They may reset/dispose this client or
|
|
1125
|
+
// capture a deterministic destination. In all three cases the reserved id no longer belongs to
|
|
1126
|
+
// the current lifecycle and must never cross the network boundary.
|
|
1127
|
+
if (disposed) return 'ineligible';
|
|
1128
|
+
if (lifecycleGeneration !== startingLifecycleGeneration || deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1129
|
+
if (options.storage && !firstOpenReservationPersisted) {
|
|
1130
|
+
// An unpersisted id must never leave the process: after a crash the retry would mint a new
|
|
1131
|
+
// id and the server could count/route the same install twice. Keep the exact nonce in memory,
|
|
1132
|
+
// arm a persistence retry without incrementing matcher attempts, and send nothing until a
|
|
1133
|
+
// synchronous durable write succeeds. Memory-only clients have no restart contract and may
|
|
1134
|
+
// continue normally.
|
|
1135
|
+
state = {
|
|
1136
|
+
...state,
|
|
1137
|
+
status: 'retryable_error',
|
|
1138
|
+
firstOpen: {
|
|
1139
|
+
...state.firstOpen,
|
|
1140
|
+
nextRetryAt: now() + retryBaseMs
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
reportError(new Error('Pulse Links: first-open install attempt id was not durably persisted'));
|
|
1144
|
+
notifyState();
|
|
1145
|
+
return 'retry_scheduled';
|
|
1146
|
+
}
|
|
1147
|
+
const accountEmail = normalizeAccountEmail(context.accountEmail);
|
|
703
1148
|
const body = {
|
|
704
1149
|
appBundleId: context.appBundleId.trim(),
|
|
705
1150
|
platform: 'ios',
|
|
706
1151
|
locale: normalizeLocale(context.locale),
|
|
707
1152
|
firstOpen: true,
|
|
708
1153
|
installAttemptId,
|
|
709
|
-
...normalizeAnonymousFirstOpenSignals(context)
|
|
1154
|
+
...normalizeAnonymousFirstOpenSignals(context),
|
|
1155
|
+
// Sent only when the app already knows it. The resolver treats an address that matches
|
|
1156
|
+
// exactly one recent message as proof of origin; anything else falls back to the
|
|
1157
|
+
// probabilistic path, so a shared or unknown address costs nothing.
|
|
1158
|
+
...(accountEmail ? {
|
|
1159
|
+
accountEmail
|
|
1160
|
+
} : {})
|
|
710
1161
|
};
|
|
711
1162
|
try {
|
|
712
1163
|
const response = await withTimeout(fetcher, matchUrlOf(resolverBaseUrl), {
|
|
@@ -717,26 +1168,23 @@ function createPulseLinkClient(options) {
|
|
|
717
1168
|
},
|
|
718
1169
|
body: JSON.stringify(body)
|
|
719
1170
|
}, requestTimeoutMs);
|
|
720
|
-
if (
|
|
721
|
-
if (state.firstOpen.completed) return 'deterministic_pending';
|
|
1171
|
+
if (disposed) return 'ineligible';
|
|
1172
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
722
1173
|
if (response.status === 204) {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
patchState({
|
|
729
|
-
status: 'idle'
|
|
1174
|
+
completeFirstOpen('idle', {
|
|
1175
|
+
status: 'NOT_FOUND',
|
|
1176
|
+
rail: 'no_route',
|
|
1177
|
+
routed: false,
|
|
1178
|
+
reason: firstOpenNoRouteReason(response)
|
|
730
1179
|
});
|
|
731
1180
|
return 'no_match';
|
|
732
1181
|
}
|
|
733
|
-
if (response.status === 400 || response.status === 404 || response.status === 410) {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
status: 'terminal_error'
|
|
1182
|
+
if (response.status === 400 || response.status === 404 || response.status === 410 || response.status === 413) {
|
|
1183
|
+
completeFirstOpen('terminal_error', {
|
|
1184
|
+
status: 'FAILURE',
|
|
1185
|
+
rail: 'no_route',
|
|
1186
|
+
routed: false,
|
|
1187
|
+
reason: 'invalid_request'
|
|
740
1188
|
});
|
|
741
1189
|
return 'terminal_error';
|
|
742
1190
|
}
|
|
@@ -745,45 +1193,98 @@ function createPulseLinkClient(options) {
|
|
|
745
1193
|
scheduleFirstOpenRetry();
|
|
746
1194
|
return 'retry_scheduled';
|
|
747
1195
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1196
|
+
completeFirstOpen('terminal_error', {
|
|
1197
|
+
status: 'FAILURE',
|
|
1198
|
+
rail: 'no_route',
|
|
1199
|
+
routed: false,
|
|
1200
|
+
reason: 'invalid_response'
|
|
751
1201
|
});
|
|
752
|
-
|
|
753
|
-
|
|
1202
|
+
return 'terminal_error';
|
|
1203
|
+
}
|
|
1204
|
+
let rawLink;
|
|
1205
|
+
try {
|
|
1206
|
+
rawLink = await response.json();
|
|
1207
|
+
} catch {
|
|
1208
|
+
completeFirstOpen('terminal_error', {
|
|
1209
|
+
status: 'FAILURE',
|
|
1210
|
+
rail: 'no_route',
|
|
1211
|
+
routed: false,
|
|
1212
|
+
reason: 'invalid_response'
|
|
754
1213
|
});
|
|
755
1214
|
return 'terminal_error';
|
|
756
1215
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
if (!link
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1216
|
+
if (disposed) return 'ineligible';
|
|
1217
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1218
|
+
const link = normalizeResolved(rawLink, installAttemptId.replace(/-/g, ''), 'unique_probabilistic', allowedActions, now());
|
|
1219
|
+
if (!link) {
|
|
1220
|
+
completeFirstOpen('terminal_error', {
|
|
1221
|
+
status: 'FAILURE',
|
|
1222
|
+
rail: 'no_route',
|
|
1223
|
+
routed: false,
|
|
1224
|
+
reason: 'invalid_response'
|
|
764
1225
|
});
|
|
765
|
-
|
|
766
|
-
|
|
1226
|
+
return 'terminal_error';
|
|
1227
|
+
}
|
|
1228
|
+
// A probabilistic result is navigation intent, never a remote URL transport. Only the two
|
|
1229
|
+
// harmless closed discovery actions are accepted; all billing/account/entitlement actions
|
|
1230
|
+
// and even an app-owned deepLink are rejected before application code runs.
|
|
1231
|
+
if (link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed || link.deepLink !== undefined || !link.action || !PROBABILISTIC_ACTIONS.has(String(link.action)) || sensitiveActions.has(link.action) || !options.onAction) {
|
|
1232
|
+
completeFirstOpen('terminal_error', {
|
|
1233
|
+
status: 'FAILURE',
|
|
1234
|
+
rail: 'no_route',
|
|
1235
|
+
routed: false,
|
|
1236
|
+
reason: 'policy_rejected'
|
|
767
1237
|
});
|
|
768
1238
|
return 'terminal_error';
|
|
769
1239
|
}
|
|
1240
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
770
1241
|
if (await applyResolved(link, null)) {
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1242
|
+
// `applyResolved()` returns true only after the host callback crossed its irreversible
|
|
1243
|
+
// commit boundary successfully. A deterministic capture can arrive while that callback
|
|
1244
|
+
// awaits; it remains pending for the next lifecycle, but it must not erase the terminal
|
|
1245
|
+
// truth that this probabilistic journey really routed.
|
|
1246
|
+
completeFirstOpen(state.pending ? 'pending' : 'applied', {
|
|
1247
|
+
status: 'FOUND',
|
|
1248
|
+
rail: 'fast_route',
|
|
1249
|
+
routed: true,
|
|
1250
|
+
reason: 'matched',
|
|
1251
|
+
matchBasis: link.matchBasis,
|
|
1252
|
+
confidence: link.confidence,
|
|
1253
|
+
...(link.campaignId !== undefined ? {
|
|
1254
|
+
campaignId: link.campaignId
|
|
1255
|
+
} : {}),
|
|
1256
|
+
...(link.experimentId !== undefined ? {
|
|
1257
|
+
experimentId: link.experimentId
|
|
1258
|
+
} : {}),
|
|
1259
|
+
...(link.variantId !== undefined ? {
|
|
1260
|
+
variantId: link.variantId
|
|
1261
|
+
} : {})
|
|
775
1262
|
});
|
|
776
1263
|
return 'matched';
|
|
777
1264
|
}
|
|
1265
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
778
1266
|
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
779
1267
|
return 'retry_scheduled';
|
|
780
1268
|
} catch (error) {
|
|
1269
|
+
if (disposed) return 'ineligible';
|
|
781
1270
|
reportError(error);
|
|
1271
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
782
1272
|
scheduleFirstOpenRetry();
|
|
783
1273
|
return 'retry_scheduled';
|
|
784
1274
|
}
|
|
785
1275
|
};
|
|
1276
|
+
const matchFirstOpen = context => {
|
|
1277
|
+
if (firstOpenMatching) return firstOpenMatching;
|
|
1278
|
+
let owned;
|
|
1279
|
+
owned = matchFirstOpenOnce(context).finally(() => {
|
|
1280
|
+
if (firstOpenMatching === owned) firstOpenMatching = null;
|
|
1281
|
+
});
|
|
1282
|
+
firstOpenMatching = owned;
|
|
1283
|
+
return owned;
|
|
1284
|
+
};
|
|
786
1285
|
const captureAndroidInstallReferrer = async bridge => {
|
|
1286
|
+
const referrerLifecycleGeneration = lifecycleGeneration;
|
|
1287
|
+
const referrerDeterministicEpoch = deterministicEpoch;
|
|
787
1288
|
let raw;
|
|
788
1289
|
try {
|
|
789
1290
|
raw = await bridge.getDeferredHandoff();
|
|
@@ -796,6 +1297,7 @@ function createPulseLinkClient(options) {
|
|
|
796
1297
|
};
|
|
797
1298
|
}
|
|
798
1299
|
const result = normalizeAndroidInstallReferrerResult(raw);
|
|
1300
|
+
if (disposed || lifecycleGeneration !== referrerLifecycleGeneration || deterministicEpoch !== referrerDeterministicEpoch) return result;
|
|
799
1301
|
if (result.status === 'OK' && result.token) {
|
|
800
1302
|
capture(result.token, 'android_install_referrer');
|
|
801
1303
|
}
|
|
@@ -807,13 +1309,14 @@ function createPulseLinkClient(options) {
|
|
|
807
1309
|
retryTimer = null;
|
|
808
1310
|
}
|
|
809
1311
|
if (disposed || options.autoRetry === false) return;
|
|
810
|
-
const candidates = [state.pending?.nextRetryAt ?? 0, lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0, accountRetryAt, state.outcomeQueue[0]?.nextRetryAt ?? 0].filter(value => value > now());
|
|
1312
|
+
const candidates = [state.pending?.nextRetryAt ?? 0, lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0, accountRetryAt, state.outcomeQueue[0]?.nextRetryAt ?? 0, state.terminalDelivery?.nextRetryAt ?? 0].filter(value => value > now());
|
|
811
1313
|
if (candidates.length === 0) return;
|
|
812
1314
|
const next = Math.min(...candidates);
|
|
813
1315
|
retryTimer = setTimeout(() => {
|
|
814
1316
|
retryTimer = null;
|
|
815
1317
|
void process();
|
|
816
1318
|
void flushResolverOutcomes();
|
|
1319
|
+
void flushTerminalDelivery();
|
|
817
1320
|
if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
|
|
818
1321
|
void matchFirstOpen(lastFirstOpenContext);
|
|
819
1322
|
}
|
|
@@ -833,6 +1336,7 @@ function createPulseLinkClient(options) {
|
|
|
833
1336
|
scheduleWake();
|
|
834
1337
|
void process();
|
|
835
1338
|
void flushResolverOutcomes();
|
|
1339
|
+
void flushTerminalDelivery();
|
|
836
1340
|
return {
|
|
837
1341
|
capture,
|
|
838
1342
|
captureUrl: url => {
|
|
@@ -850,6 +1354,9 @@ function createPulseLinkClient(options) {
|
|
|
850
1354
|
dispose: () => {
|
|
851
1355
|
if (disposed) return;
|
|
852
1356
|
disposed = true;
|
|
1357
|
+
lifecycleGeneration += 1;
|
|
1358
|
+
deterministicEpoch += 1;
|
|
1359
|
+
terminalDeliveryGeneration += 1;
|
|
853
1360
|
if (retryTimer) clearTimeout(retryTimer);
|
|
854
1361
|
retryTimer = null;
|
|
855
1362
|
unsubscribeAccount?.();
|
|
@@ -859,6 +1366,11 @@ function createPulseLinkClient(options) {
|
|
|
859
1366
|
reset: () => {
|
|
860
1367
|
accountRetryAt = 0;
|
|
861
1368
|
accountAttempts = 0;
|
|
1369
|
+
lifecycleGeneration += 1;
|
|
1370
|
+
deterministicEpoch += 1;
|
|
1371
|
+
terminalDeliveryGeneration += 1;
|
|
1372
|
+
terminalDeliverySending = null;
|
|
1373
|
+
deterministicTokenDeferredAfterCommittedJourney = null;
|
|
862
1374
|
state = emptyState();
|
|
863
1375
|
persistAndNotify();
|
|
864
1376
|
}
|
|
@@ -902,11 +1414,11 @@ function stableOutcomeKey(linkId, name) {
|
|
|
902
1414
|
}
|
|
903
1415
|
return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}|${name}`;
|
|
904
1416
|
}
|
|
905
|
-
function boundedMetadata(value) {
|
|
1417
|
+
function boundedMetadata(value, maxLength = 256) {
|
|
906
1418
|
if (value === null) return null;
|
|
907
1419
|
if (typeof value !== 'string' && typeof value !== 'number') return undefined;
|
|
908
1420
|
const result = String(value).trim();
|
|
909
|
-
return result.length > 0 && result.length <=
|
|
1421
|
+
return result.length > 0 && result.length <= maxLength ? result : undefined;
|
|
910
1422
|
}
|
|
911
1423
|
function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now) {
|
|
912
1424
|
if (!raw || typeof raw !== 'object') return null;
|
|
@@ -928,9 +1440,9 @@ function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now)
|
|
|
928
1440
|
if (!Number.isFinite(expiry) || expiry <= now) return null;
|
|
929
1441
|
}
|
|
930
1442
|
const source = boundedMetadata(value.source);
|
|
931
|
-
const campaignId = boundedMetadata(value.campaignId);
|
|
932
|
-
const experimentId = boundedMetadata(value.experimentId);
|
|
933
|
-
const variantId = boundedMetadata(value.variantId);
|
|
1443
|
+
const campaignId = boundedMetadata(value.campaignId, 128);
|
|
1444
|
+
const experimentId = boundedMetadata(value.experimentId, 128);
|
|
1445
|
+
const variantId = boundedMetadata(value.variantId, 128);
|
|
934
1446
|
return {
|
|
935
1447
|
id,
|
|
936
1448
|
...(action ? {
|
|
@@ -967,6 +1479,15 @@ function isEligibleFirstOpen(context, now, maxAgeMs) {
|
|
|
967
1479
|
function normalizeLocale(locale) {
|
|
968
1480
|
return locale.trim().replace(/_/g, '-');
|
|
969
1481
|
}
|
|
1482
|
+
function firstOpenNoRouteReason(response) {
|
|
1483
|
+
let raw = '';
|
|
1484
|
+
try {
|
|
1485
|
+
raw = response.headers?.get('X-Encore-Match-Outcome')?.trim().toLowerCase() ?? '';
|
|
1486
|
+
} catch {
|
|
1487
|
+
raw = '';
|
|
1488
|
+
}
|
|
1489
|
+
return NO_ROUTE_REASONS.has(raw) ? raw : 'unmatched';
|
|
1490
|
+
}
|
|
970
1491
|
function normalizeAnonymousFirstOpenSignals(context) {
|
|
971
1492
|
const appVersion = normalizeSignalString(context.appVersion, VERSION_SIGNAL, 64);
|
|
972
1493
|
const osVersion = normalizeSignalString(context.osVersion, VERSION_SIGNAL, 64);
|
|
@@ -1046,6 +1567,20 @@ function resolverOutcomeRetryDelay(eventId, attempts, retryBaseMs, retryMaxMs) {
|
|
|
1046
1567
|
const jitter = 0.5 + hash / 0xffffffff * 0.5;
|
|
1047
1568
|
return Math.max(1, Math.floor(ceiling * jitter));
|
|
1048
1569
|
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* An address is only useful to the resolver if it is the same shape the send was recorded with.
|
|
1573
|
+
* Anything that is not plausibly an address is dropped rather than sent: a malformed value can
|
|
1574
|
+
* only ever fail to match, and not sending it keeps the payload free of stray user input.
|
|
1575
|
+
*/
|
|
1576
|
+
function normalizeAccountEmail(raw) {
|
|
1577
|
+
const value = (raw ?? '').trim().toLowerCase();
|
|
1578
|
+
if (value.length < 3 || value.length > 255) return null;
|
|
1579
|
+
const at = value.indexOf('@');
|
|
1580
|
+
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return null;
|
|
1581
|
+
if (/\s/.test(value)) return null;
|
|
1582
|
+
return value;
|
|
1583
|
+
}
|
|
1049
1584
|
async function withTimeout(fetcher, input, init, timeoutMs) {
|
|
1050
1585
|
const controller = new AbortController();
|
|
1051
1586
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -1058,6 +1593,31 @@ async function withTimeout(fetcher, input, init, timeoutMs) {
|
|
|
1058
1593
|
clearTimeout(timer);
|
|
1059
1594
|
}
|
|
1060
1595
|
}
|
|
1596
|
+
async function withPromiseTimeout(factory, timeoutMs, operation) {
|
|
1597
|
+
let timer = null;
|
|
1598
|
+
try {
|
|
1599
|
+
return await Promise.race([Promise.resolve().then(factory), new Promise((_resolve, reject) => {
|
|
1600
|
+
timer = setTimeout(() => reject(new Error(`Pulse Links: ${operation} timed out`)), timeoutMs);
|
|
1601
|
+
})]);
|
|
1602
|
+
} finally {
|
|
1603
|
+
if (timer) clearTimeout(timer);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
async function withTerminalDeliveryTimeout(acknowledgement, timeoutMs) {
|
|
1607
|
+
let timer = null;
|
|
1608
|
+
try {
|
|
1609
|
+
return await Promise.race([acknowledgement, new Promise((_resolve, reject) => {
|
|
1610
|
+
timer = setTimeout(() => reject(new Error('Pulse Links: terminal delivery acknowledgement timed out')), timeoutMs);
|
|
1611
|
+
})]);
|
|
1612
|
+
} finally {
|
|
1613
|
+
if (timer) clearTimeout(timer);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
function normalizeTerminalDeliveryDisposition(value) {
|
|
1617
|
+
if (value === true || value === 'accepted') return 'accepted';
|
|
1618
|
+
if (value === 'drop') return 'drop';
|
|
1619
|
+
return 'retry';
|
|
1620
|
+
}
|
|
1061
1621
|
async function postResolverOutcome(fetcher, resolverBaseUrl, outcome, timeoutMs) {
|
|
1062
1622
|
return withTimeout(fetcher, `${resolverBaseUrl}${encodeURIComponent(outcome.token)}/event/${outcome.name}`, {
|
|
1063
1623
|
method: 'POST',
|
|
@@ -1124,6 +1684,56 @@ function normalizeOccurredAt(value) {
|
|
|
1124
1684
|
if (!Number.isFinite(timestamp)) return null;
|
|
1125
1685
|
return new Date(timestamp).toISOString() === value ? value : null;
|
|
1126
1686
|
}
|
|
1687
|
+
function readTerminalDelivery(raw) {
|
|
1688
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
1689
|
+
const value = raw;
|
|
1690
|
+
const eventId = typeof value.eventId === 'string' && UUID_V4.test(value.eventId) ? value.eventId.toLowerCase() : null;
|
|
1691
|
+
const status = value.status === 'FOUND' || value.status === 'NOT_FOUND' || value.status === 'FAILURE' ? value.status : null;
|
|
1692
|
+
const rail = value.rail === 'fast_route' || value.rail === 'no_route' ? value.rail : null;
|
|
1693
|
+
const reason = typeof value.reason === 'string' ? value.reason : null;
|
|
1694
|
+
const occurredAt = normalizeOccurredAt(value.occurredAt);
|
|
1695
|
+
const attempts = typeof value.attempts === 'number' && Number.isSafeInteger(value.attempts) && value.attempts >= 0 && value.attempts <= 100_000 ? value.attempts : null;
|
|
1696
|
+
const nextRetryAt = typeof value.nextRetryAt === 'number' && Number.isSafeInteger(value.nextRetryAt) && value.nextRetryAt >= 0 && value.nextRetryAt <= 8_640_000_000_000_000 ? value.nextRetryAt : null;
|
|
1697
|
+
if (value.disposition === 'drop') {
|
|
1698
|
+
const allowedKeys = new Set(['eventId', 'disposition', 'attempts', 'nextRetryAt']);
|
|
1699
|
+
if (!eventId || attempts === null || nextRetryAt === null || Object.keys(value).some(key => !allowedKeys.has(key))) return null;
|
|
1700
|
+
return {
|
|
1701
|
+
eventId,
|
|
1702
|
+
disposition: 'drop',
|
|
1703
|
+
attempts,
|
|
1704
|
+
nextRetryAt
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
const matchBasis = typeof value.matchBasis === 'string' && MATCH_BASES.has(value.matchBasis) ? value.matchBasis : null;
|
|
1708
|
+
const confidence = typeof value.confidence === 'number' && Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : null;
|
|
1709
|
+
const metadata = {};
|
|
1710
|
+
let metadataValid = true;
|
|
1711
|
+
for (const key of ['campaignId', 'experimentId', 'variantId']) {
|
|
1712
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
|
|
1713
|
+
const field = value[key];
|
|
1714
|
+
if (field === null) metadata[key] = null;else if (typeof field === 'string' && field.length > 0 && field.length <= 128 && field.trim() === field) metadata[key] = field;else metadataValid = false;
|
|
1715
|
+
}
|
|
1716
|
+
const hasFoundDimensions = Object.prototype.hasOwnProperty.call(value, 'matchBasis') && Object.prototype.hasOwnProperty.call(value, 'confidence');
|
|
1717
|
+
const hasAnyAttributionDimension = Object.prototype.hasOwnProperty.call(value, 'matchBasis') || Object.prototype.hasOwnProperty.call(value, 'confidence') || Object.prototype.hasOwnProperty.call(value, 'campaignId') || Object.prototype.hasOwnProperty.call(value, 'experimentId') || Object.prototype.hasOwnProperty.call(value, 'variantId');
|
|
1718
|
+
const validSemanticOutcome = status === 'FOUND' ? rail === 'fast_route' && value.routed === true && reason === 'matched' && hasFoundDimensions && matchBasis === 'unique_probabilistic' && confidence !== null && metadataValid : status === 'NOT_FOUND' ? rail === 'no_route' && value.routed === false && reason !== null && NO_ROUTE_REASONS.has(reason) && !hasAnyAttributionDimension : status === 'FAILURE' ? rail === 'no_route' && value.routed === false && reason !== null && FAILURE_REASONS.has(reason) && !hasAnyAttributionDimension : false;
|
|
1719
|
+
if (!eventId || !status || !rail || !reason || !occurredAt || attempts === null || nextRetryAt === null || Object.prototype.hasOwnProperty.call(value, 'disposition') || value.retryable !== false || !validSemanticOutcome) return null;
|
|
1720
|
+
return {
|
|
1721
|
+
eventId,
|
|
1722
|
+
status,
|
|
1723
|
+
rail,
|
|
1724
|
+
routed: value.routed,
|
|
1725
|
+
retryable: false,
|
|
1726
|
+
reason,
|
|
1727
|
+
occurredAt,
|
|
1728
|
+
...(status === 'FOUND' ? {
|
|
1729
|
+
matchBasis: matchBasis,
|
|
1730
|
+
confidence: confidence,
|
|
1731
|
+
...metadata
|
|
1732
|
+
} : {}),
|
|
1733
|
+
attempts,
|
|
1734
|
+
nextRetryAt
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1127
1737
|
function readState(storage, key) {
|
|
1128
1738
|
if (!storage) return emptyState();
|
|
1129
1739
|
try {
|
|
@@ -1150,6 +1760,7 @@ function readState(storage, key) {
|
|
|
1150
1760
|
const attemptId = typeof firstValue.installAttemptId === 'string' && INSTALL_ATTEMPT_ID.test(firstValue.installAttemptId) ? firstValue.installAttemptId.toLowerCase() : null;
|
|
1151
1761
|
const notifiedOutcomes = Array.isArray(parsed.notifiedOutcomes) ? parsed.notifiedOutcomes.filter(value => typeof value === 'string' && /^[a-f0-9]{16}\|(app_open_confirmed|deferred_link_resolved|action_applied)$/.test(value)).slice(-MAX_NOTIFIED_OUTCOMES) : [];
|
|
1152
1762
|
const outcomeQueue = readResolverOutcomeQueue(parsed.outcomeQueue);
|
|
1763
|
+
const terminalDelivery = readTerminalDelivery(parsed.terminalDelivery);
|
|
1153
1764
|
return {
|
|
1154
1765
|
version: 1,
|
|
1155
1766
|
status: status === 'resolving' ? pending ? 'pending' : 'idle' : status,
|
|
@@ -1158,6 +1769,7 @@ function readState(storage, key) {
|
|
|
1158
1769
|
appliedIds: [...new Set(appliedIds)].slice(-MAX_APPLIED_IDS),
|
|
1159
1770
|
notifiedOutcomes: [...new Set([...notifiedOutcomes, ...outcomeQueue.map(outcome => outcome.transitionKey)])].slice(-MAX_NOTIFIED_OUTCOMES),
|
|
1160
1771
|
outcomeQueue,
|
|
1772
|
+
terminalDelivery,
|
|
1161
1773
|
firstOpen: {
|
|
1162
1774
|
installAttemptId: attemptId,
|
|
1163
1775
|
completed: firstValue.completed === true,
|
|
@@ -1170,11 +1782,24 @@ function readState(storage, key) {
|
|
|
1170
1782
|
return emptyState();
|
|
1171
1783
|
}
|
|
1172
1784
|
}
|
|
1173
|
-
function writeState(storage, key, state) {
|
|
1174
|
-
if (!storage) return;
|
|
1785
|
+
function writeState(storage, key, state, allowKnownAsyncBestEffort = true) {
|
|
1786
|
+
if (!storage) return true;
|
|
1787
|
+
const knownAsync = storage.supportsDurableSyncWrites === false || ASYNCHRONOUS_STORAGE_ADAPTERS.has(storage);
|
|
1788
|
+
if (knownAsync && !allowKnownAsyncBestEffort) return false;
|
|
1175
1789
|
try {
|
|
1176
|
-
|
|
1177
|
-
|
|
1790
|
+
const result = storage.set(key, JSON.stringify(state));
|
|
1791
|
+
if (isThenable(result)) {
|
|
1792
|
+
ASYNCHRONOUS_STORAGE_ADAPTERS.add(storage);
|
|
1793
|
+
void Promise.resolve(result).catch(() => undefined);
|
|
1794
|
+
return false;
|
|
1795
|
+
}
|
|
1796
|
+
return !knownAsync;
|
|
1797
|
+
} catch {
|
|
1798
|
+
return false;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
function isThenable(value) {
|
|
1802
|
+
return typeof value === 'object' && value !== null || typeof value === 'function' ? typeof value.then === 'function' : false;
|
|
1178
1803
|
}
|
|
1179
1804
|
function safeTimestamp(value) {
|
|
1180
1805
|
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|