pulse-updates 1.3.6 → 1.3.8
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 +50 -2
- package/lib/commonjs/config.js.map +1 -1
- package/lib/commonjs/init.js +23 -8
- package/lib/commonjs/init.js.map +1 -1
- package/lib/commonjs/links.js +561 -61
- package/lib/commonjs/links.js.map +1 -1
- package/lib/commonjs/track.js +241 -76
- package/lib/commonjs/track.js.map +1 -1
- package/lib/module/config.js.map +1 -1
- package/lib/module/init.js +24 -9
- package/lib/module/init.js.map +1 -1
- package/lib/module/links.js +561 -61
- package/lib/module/links.js.map +1 -1
- package/lib/module/track.js +240 -76
- 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 +15 -6
- package/lib/typescript/init.d.ts.map +1 -1
- package/lib/typescript/links.d.ts +49 -0
- package/lib/typescript/links.d.ts.map +1 -1
- package/lib/typescript/track.d.ts +29 -7
- package/lib/typescript/track.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +6 -0
- package/src/init.ts +33 -7
- package/src/links.ts +810 -40
- package/src/track.ts +348 -83
package/lib/commonjs/links.js
CHANGED
|
@@ -18,6 +18,14 @@ exports.normalizeDeferredHandoffToken = normalizeDeferredHandoffToken;
|
|
|
18
18
|
* optional account bridge below.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
/** Explicit receiver decision for one terminal-delivery attempt. */
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Sanitized terminal result of the anonymous first-open rail. It deliberately carries no
|
|
25
|
+
* install-attempt id, token, device signal or raw server error. A 204 is always `no_route` and
|
|
26
|
+
* can never make the client navigate, including when Encore reports a shadow observation.
|
|
27
|
+
*/
|
|
28
|
+
|
|
21
29
|
/** Implemented by the separate native `PulseAttribution` module. */
|
|
22
30
|
|
|
23
31
|
const OPAQUE_TOKEN = /^[A-Za-z0-9_-]{16,512}$/;
|
|
@@ -34,6 +42,14 @@ const STATUSES = new Set(['idle', 'pending', 'resolving', 'waiting_for_account',
|
|
|
34
42
|
const INSTALL_REFERRER_STATUSES = new Set(['OK', 'NO_TOKEN', 'FEATURE_NOT_SUPPORTED', 'SERVICE_UNAVAILABLE', 'DEVELOPER_ERROR', 'SERVICE_DISCONNECTED']);
|
|
35
43
|
const DEFAULT_ACTIONS = ['open_home', 'open_premium', 'manage_subscription', 'refresh_entitlement'];
|
|
36
44
|
const DEFAULT_SENSITIVE_ACTIONS = ['manage_subscription', 'refresh_entitlement'];
|
|
45
|
+
const PROBABILISTIC_ACTIONS = new Set(['open_home', 'open_premium']);
|
|
46
|
+
const CAPTURE_PRIORITY = {
|
|
47
|
+
direct_token: 3,
|
|
48
|
+
ios_user_paste: 2,
|
|
49
|
+
android_install_referrer: 1
|
|
50
|
+
};
|
|
51
|
+
const NO_ROUTE_REASONS = new Set(['unmatched', 'ambiguous', 'holdout', 'low_confidence', 'shadow_would_route', 'shadow_attributed', 'analytics_attributed', 'target_revoked', 'disabled', 'expired_replay']);
|
|
52
|
+
const FAILURE_REASONS = new Set(['invalid_request', 'invalid_response', 'policy_rejected']);
|
|
37
53
|
const DEFAULT_STORAGE_KEY = 'pulse.links.v1';
|
|
38
54
|
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
39
55
|
const DEFAULT_RETRY_BASE_MS = 15_000;
|
|
@@ -44,10 +60,14 @@ const MAX_APPLIED_IDS = 32;
|
|
|
44
60
|
const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
|
|
45
61
|
const MAX_RESOLVER_OUTCOMES = MAX_NOTIFIED_OUTCOMES;
|
|
46
62
|
const MAX_PERSISTED_BYTES = 131_072;
|
|
63
|
+
const ASYNCHRONOUS_STORAGE_ADAPTERS = new WeakSet();
|
|
47
64
|
const VERSION_SIGNAL = /^[A-Za-z0-9][A-Za-z0-9._+()-]*$/;
|
|
48
65
|
const DEVICE_MODEL_CODE = /^[A-Za-z0-9][A-Za-z0-9._,+-]*$/;
|
|
49
66
|
const DISTRIBUTION_SIGNAL = /^[a-z0-9][a-z0-9._-]*$/;
|
|
50
67
|
const DEVICE_TYPES = new Set(['phone', 'tablet', 'tv', 'desktop', 'gaming_console', 'unknown']);
|
|
68
|
+
|
|
69
|
+
/** Minimal sticky privacy tombstone. It carries no terminal outcome or attribution metadata. */
|
|
70
|
+
|
|
51
71
|
const emptyState = () => ({
|
|
52
72
|
version: 1,
|
|
53
73
|
status: 'idle',
|
|
@@ -56,6 +76,7 @@ const emptyState = () => ({
|
|
|
56
76
|
appliedIds: [],
|
|
57
77
|
notifiedOutcomes: [],
|
|
58
78
|
outcomeQueue: [],
|
|
79
|
+
terminalDelivery: null,
|
|
59
80
|
firstOpen: {
|
|
60
81
|
installAttemptId: null,
|
|
61
82
|
completed: false,
|
|
@@ -156,6 +177,7 @@ function createPulseLinkClient(options) {
|
|
|
156
177
|
}
|
|
157
178
|
const storageKey = explicitStorageKey || (appSlug ? `pulse.${appSlug}.links.v1` : DEFAULT_STORAGE_KEY);
|
|
158
179
|
const requestTimeoutMs = boundedDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 500, 60_000);
|
|
180
|
+
const terminalDeliveryTimeoutMs = boundedDuration(options.terminalDeliveryTimeoutMs, requestTimeoutMs, 100, 60_000);
|
|
159
181
|
const retryBaseMs = boundedDuration(options.retryBaseMs, DEFAULT_RETRY_BASE_MS, 100, 60 * 60 * 1_000);
|
|
160
182
|
const retryMaxMs = boundedDuration(options.retryMaxMs, DEFAULT_RETRY_MAX_MS, retryBaseMs, 24 * 60 * 60 * 1_000);
|
|
161
183
|
const tokenMaxAgeMs = boundedDuration(options.tokenMaxAgeMs, DEFAULT_TOKEN_MAX_AGE_MS, 60_000, 365 * 24 * 60 * 60 * 1_000);
|
|
@@ -164,11 +186,21 @@ function createPulseLinkClient(options) {
|
|
|
164
186
|
let state = readState(options.storage, storageKey);
|
|
165
187
|
let disposed = false;
|
|
166
188
|
let processing = null;
|
|
189
|
+
let firstOpenMatching = null;
|
|
167
190
|
let processRequested = false;
|
|
168
191
|
let retryTimer = null;
|
|
169
192
|
let outcomeSending = null;
|
|
193
|
+
let terminalDeliverySending = null;
|
|
194
|
+
let terminalDeliveryGeneration = 0;
|
|
170
195
|
let accountRetryAt = 0;
|
|
171
196
|
let accountAttempts = 0;
|
|
197
|
+
let deterministicEpoch = 0;
|
|
198
|
+
// Host navigation is irreversible once any routing callback has started: it may perform its
|
|
199
|
+
// side effect synchronously before returning a Promise. Serialize captures against that commit
|
|
200
|
+
// boundary and keep the newer accepted token durable for the next client lifecycle instead of
|
|
201
|
+
// automatically opening a second journey behind the first one.
|
|
202
|
+
let routingApplicationInFlight = false;
|
|
203
|
+
let deterministicTokenDeferredAfterCommittedJourney = null;
|
|
172
204
|
let lastFirstOpenContext = null;
|
|
173
205
|
let unsubscribeAccount = null;
|
|
174
206
|
const listeners = new Set();
|
|
@@ -177,6 +209,25 @@ function createPulseLinkClient(options) {
|
|
|
177
209
|
options.onError?.(error);
|
|
178
210
|
} catch {/* diagnostic hooks never break link handling */}
|
|
179
211
|
};
|
|
212
|
+
|
|
213
|
+
// A build without a receiver has not opted into retaining this analytics envelope. First replace
|
|
214
|
+
// an old full record with the same minimal sticky tombstone used by an explicit drop. Deletion
|
|
215
|
+
// may fail, but a later app version can then only retry local deletion, never resurrect delivery.
|
|
216
|
+
if (!options.onFirstOpenResult && state.terminalDelivery && state.terminalDelivery.disposition !== 'drop') {
|
|
217
|
+
const tombstone = {
|
|
218
|
+
eventId: state.terminalDelivery.eventId,
|
|
219
|
+
disposition: 'drop',
|
|
220
|
+
attempts: state.terminalDelivery.attempts,
|
|
221
|
+
nextRetryAt: 0
|
|
222
|
+
};
|
|
223
|
+
state = {
|
|
224
|
+
...state,
|
|
225
|
+
terminalDelivery: tombstone
|
|
226
|
+
};
|
|
227
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
228
|
+
reportError(new Error('Pulse Links: stale terminal delivery tombstone was not persisted'));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
180
231
|
const snapshot = () => ({
|
|
181
232
|
status: state.status,
|
|
182
233
|
pending: state.pending ? {
|
|
@@ -188,8 +239,7 @@ function createPulseLinkClient(options) {
|
|
|
188
239
|
...state.firstOpen
|
|
189
240
|
}
|
|
190
241
|
});
|
|
191
|
-
const
|
|
192
|
-
writeState(options.storage, storageKey, state);
|
|
242
|
+
const notifyState = () => {
|
|
193
243
|
const value = snapshot();
|
|
194
244
|
for (const listener of listeners) {
|
|
195
245
|
try {
|
|
@@ -205,6 +255,11 @@ function createPulseLinkClient(options) {
|
|
|
205
255
|
}
|
|
206
256
|
scheduleWake();
|
|
207
257
|
};
|
|
258
|
+
const persistAndNotify = () => {
|
|
259
|
+
const persisted = writeState(options.storage, storageKey, state);
|
|
260
|
+
notifyState();
|
|
261
|
+
return persisted;
|
|
262
|
+
};
|
|
208
263
|
const patchState = patch => {
|
|
209
264
|
state = {
|
|
210
265
|
...state,
|
|
@@ -222,6 +277,205 @@ function createPulseLinkClient(options) {
|
|
|
222
277
|
};
|
|
223
278
|
persistAndNotify();
|
|
224
279
|
};
|
|
280
|
+
const terminalDeliveryOutcome = queued => ({
|
|
281
|
+
eventId: queued.eventId,
|
|
282
|
+
status: queued.status,
|
|
283
|
+
rail: queued.rail,
|
|
284
|
+
routed: queued.routed,
|
|
285
|
+
retryable: false,
|
|
286
|
+
reason: queued.reason,
|
|
287
|
+
occurredAt: queued.occurredAt,
|
|
288
|
+
...(queued.matchBasis !== undefined ? {
|
|
289
|
+
matchBasis: queued.matchBasis
|
|
290
|
+
} : {}),
|
|
291
|
+
...(queued.confidence !== undefined ? {
|
|
292
|
+
confidence: queued.confidence
|
|
293
|
+
} : {}),
|
|
294
|
+
...(queued.campaignId !== undefined ? {
|
|
295
|
+
campaignId: queued.campaignId
|
|
296
|
+
} : {}),
|
|
297
|
+
...(queued.experimentId !== undefined ? {
|
|
298
|
+
experimentId: queued.experimentId
|
|
299
|
+
} : {}),
|
|
300
|
+
...(queued.variantId !== undefined ? {
|
|
301
|
+
variantId: queued.variantId
|
|
302
|
+
} : {})
|
|
303
|
+
});
|
|
304
|
+
const scheduleTerminalDeliveryRetry = queued => {
|
|
305
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return;
|
|
306
|
+
const attempts = Math.min(queued.attempts + 1, 100_000);
|
|
307
|
+
state = {
|
|
308
|
+
...state,
|
|
309
|
+
terminalDelivery: {
|
|
310
|
+
...queued,
|
|
311
|
+
attempts,
|
|
312
|
+
nextRetryAt: now() + resolverOutcomeRetryDelay(queued.eventId, attempts, retryBaseMs, retryMaxMs)
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
persistAndNotify();
|
|
316
|
+
};
|
|
317
|
+
const acknowledgeTerminalDelivery = queued => {
|
|
318
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
319
|
+
const acknowledgedState = {
|
|
320
|
+
...state,
|
|
321
|
+
terminalDelivery: null
|
|
322
|
+
};
|
|
323
|
+
// Clearing the record is itself transactional. If this write fails after the receiver
|
|
324
|
+
// accepted the event, retain and replay the same eventId: that is why the contract is
|
|
325
|
+
// at-least-once and why receivers must deduplicate.
|
|
326
|
+
if (!writeState(options.storage, storageKey, acknowledgedState, false)) {
|
|
327
|
+
reportError(new Error('Pulse Links: terminal delivery acknowledgement was not persisted'));
|
|
328
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
state = acknowledgedState;
|
|
332
|
+
notifyState();
|
|
333
|
+
return true;
|
|
334
|
+
};
|
|
335
|
+
const flushTerminalDelivery = () => {
|
|
336
|
+
if (disposed) return Promise.resolve(false);
|
|
337
|
+
if (terminalDeliverySending) return terminalDeliverySending;
|
|
338
|
+
const queued = state.terminalDelivery;
|
|
339
|
+
if (!queued || queued.nextRetryAt > now()) return Promise.resolve(false);
|
|
340
|
+
if (queued.disposition === 'drop') {
|
|
341
|
+
return Promise.resolve(acknowledgeTerminalDelivery(queued));
|
|
342
|
+
}
|
|
343
|
+
if (!options.onFirstOpenResult) return Promise.resolve(false);
|
|
344
|
+
const generation = terminalDeliveryGeneration;
|
|
345
|
+
const run = async () => {
|
|
346
|
+
// Never call the receiver before the complete record is durable. With no storage adapter,
|
|
347
|
+
// this is an explicitly memory-only degradation rather than a cross-restart guarantee.
|
|
348
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
349
|
+
reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
|
|
350
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
let rawDisposition;
|
|
354
|
+
try {
|
|
355
|
+
rawDisposition = options.onFirstOpenResult(terminalDeliveryOutcome(queued));
|
|
356
|
+
} catch (error) {
|
|
357
|
+
reportError(error);
|
|
358
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
let disposition;
|
|
362
|
+
if (typeof rawDisposition === 'boolean' || typeof rawDisposition === 'string') {
|
|
363
|
+
disposition = normalizeTerminalDeliveryDisposition(rawDisposition);
|
|
364
|
+
} else {
|
|
365
|
+
try {
|
|
366
|
+
const resolved = await withTerminalDeliveryTimeout(Promise.resolve(rawDisposition), terminalDeliveryTimeoutMs);
|
|
367
|
+
disposition = normalizeTerminalDeliveryDisposition(resolved);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
if (!disposed && terminalDeliveryGeneration === generation) reportError(error);
|
|
370
|
+
if (!disposed && terminalDeliveryGeneration === generation) {
|
|
371
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
372
|
+
}
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (disposed || terminalDeliveryGeneration !== generation || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
377
|
+
if (disposition === 'accepted') return acknowledgeTerminalDelivery(queued);
|
|
378
|
+
if (disposition === 'drop') return dropTerminalDelivery(queued);
|
|
379
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
380
|
+
return false;
|
|
381
|
+
};
|
|
382
|
+
let owned;
|
|
383
|
+
owned = run().finally(() => {
|
|
384
|
+
if (terminalDeliverySending === owned) terminalDeliverySending = null;
|
|
385
|
+
const pending = state.terminalDelivery;
|
|
386
|
+
if (!disposed && options.onFirstOpenResult && pending && pending.nextRetryAt <= now()) {
|
|
387
|
+
void flushTerminalDelivery();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
terminalDeliverySending = owned;
|
|
391
|
+
return owned;
|
|
392
|
+
};
|
|
393
|
+
const dropTerminalDelivery = queued => {
|
|
394
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
395
|
+
const tombstone = {
|
|
396
|
+
eventId: queued.eventId,
|
|
397
|
+
disposition: 'drop',
|
|
398
|
+
attempts: queued.attempts,
|
|
399
|
+
nextRetryAt: 0
|
|
400
|
+
};
|
|
401
|
+
state = {
|
|
402
|
+
...state,
|
|
403
|
+
terminalDelivery: tombstone
|
|
404
|
+
};
|
|
405
|
+
// Persist the sticky disposition before trying to delete it. A failed local deletion can then
|
|
406
|
+
// retry only deletion after restart; it must never call the receiver or resurrect collection.
|
|
407
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
408
|
+
reportError(new Error('Pulse Links: terminal drop tombstone was not persisted'));
|
|
409
|
+
scheduleTerminalDeliveryRetry(tombstone);
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
notifyState();
|
|
413
|
+
return acknowledgeTerminalDelivery(tombstone);
|
|
414
|
+
};
|
|
415
|
+
const completeFirstOpen = (status, outcome) => {
|
|
416
|
+
let shouldQueue = Boolean(options.onFirstOpenResult);
|
|
417
|
+
if (shouldQueue && options.shouldQueueFirstOpenResult) {
|
|
418
|
+
try {
|
|
419
|
+
shouldQueue = options.shouldQueueFirstOpenResult() === true;
|
|
420
|
+
} catch (error) {
|
|
421
|
+
shouldQueue = false;
|
|
422
|
+
reportError(error);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (!shouldQueue) {
|
|
426
|
+
state = {
|
|
427
|
+
...state,
|
|
428
|
+
status,
|
|
429
|
+
terminalDelivery: null,
|
|
430
|
+
firstOpen: {
|
|
431
|
+
...state.firstOpen,
|
|
432
|
+
completed: true,
|
|
433
|
+
attempts: 0,
|
|
434
|
+
nextRetryAt: 0
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
persistAndNotify();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const existingEventIds = new Set([...state.outcomeQueue.map(queued => queued.eventId), ...(state.firstOpen.installAttemptId ? [state.firstOpen.installAttemptId] : []), ...(state.terminalDelivery ? [state.terminalDelivery.eventId] : [])]);
|
|
441
|
+
const delivery = state.terminalDelivery ?? {
|
|
442
|
+
...outcome,
|
|
443
|
+
eventId: makeOutcomeEventId(options.randomUUID, existingEventIds),
|
|
444
|
+
retryable: false,
|
|
445
|
+
occurredAt: new Date(now()).toISOString(),
|
|
446
|
+
attempts: 0,
|
|
447
|
+
nextRetryAt: 0
|
|
448
|
+
};
|
|
449
|
+
state = {
|
|
450
|
+
...state,
|
|
451
|
+
status,
|
|
452
|
+
terminalDelivery: delivery,
|
|
453
|
+
firstOpen: {
|
|
454
|
+
...state.firstOpen,
|
|
455
|
+
completed: true,
|
|
456
|
+
attempts: 0,
|
|
457
|
+
nextRetryAt: 0
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
if (!persistAndNotify()) {
|
|
461
|
+
reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
|
|
462
|
+
}
|
|
463
|
+
// Delivery is deliberately detached from routing/matcher completion.
|
|
464
|
+
void flushTerminalDelivery();
|
|
465
|
+
};
|
|
466
|
+
const blockFirstOpenForDeterministic = () => {
|
|
467
|
+
deterministicEpoch += 1;
|
|
468
|
+
if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return;
|
|
469
|
+
state = {
|
|
470
|
+
...state,
|
|
471
|
+
firstOpen: {
|
|
472
|
+
...state.firstOpen,
|
|
473
|
+
completed: true,
|
|
474
|
+
nextRetryAt: 0
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
persistAndNotify();
|
|
478
|
+
};
|
|
225
479
|
const isAccountReady = () => {
|
|
226
480
|
try {
|
|
227
481
|
return options.isAccountReady?.() ?? false;
|
|
@@ -454,9 +708,24 @@ function createPulseLinkClient(options) {
|
|
|
454
708
|
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
455
709
|
return false;
|
|
456
710
|
}
|
|
711
|
+
let applied = false;
|
|
712
|
+
let applicationFailed = false;
|
|
713
|
+
let applicationError;
|
|
714
|
+
const applicationEpoch = deterministicEpoch;
|
|
715
|
+
const hasNewerPendingIntent = () => deterministicEpoch !== applicationEpoch && state.pending !== null
|
|
716
|
+
// A provenance upgrade or rejected lower-priority capture for the token already being
|
|
717
|
+
// applied does not represent another destination and must not leave that token pending.
|
|
718
|
+
&& (sourceToken === null || state.pending.token !== sourceToken);
|
|
719
|
+
routingApplicationInFlight = true;
|
|
457
720
|
emitOutcome(link, 'app_open_confirmed');
|
|
458
721
|
emitOutcome(link, 'deferred_link_resolved');
|
|
459
|
-
|
|
722
|
+
// `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
|
|
723
|
+
// a newer accepted destination, it still arrived before the routing callback and must win.
|
|
724
|
+
if (hasNewerPendingIntent()) {
|
|
725
|
+
routingApplicationInFlight = false;
|
|
726
|
+
void process();
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
460
729
|
try {
|
|
461
730
|
if (link.deepLink) {
|
|
462
731
|
applied = await options.onDeepLink(link.deepLink, link);
|
|
@@ -467,18 +736,41 @@ function createPulseLinkClient(options) {
|
|
|
467
736
|
return false;
|
|
468
737
|
}
|
|
469
738
|
} catch (error) {
|
|
470
|
-
|
|
739
|
+
applicationFailed = true;
|
|
740
|
+
applicationError = error;
|
|
741
|
+
} finally {
|
|
742
|
+
routingApplicationInFlight = false;
|
|
743
|
+
}
|
|
744
|
+
const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
|
|
745
|
+
if (applicationFailed) {
|
|
746
|
+
reportError(applicationError);
|
|
747
|
+
if (newerIntentArrivedDuringApplication) {
|
|
748
|
+
// The current action did not commit. Resume the newer accepted intent that was captured
|
|
749
|
+
// while its callback was suspended, without scheduling a retry for the superseded one.
|
|
750
|
+
void process();
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
471
753
|
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
|
|
472
754
|
return false;
|
|
473
755
|
}
|
|
474
756
|
if (applied === false) {
|
|
757
|
+
if (newerIntentArrivedDuringApplication) {
|
|
758
|
+
void process();
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
475
761
|
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
|
|
476
762
|
return false;
|
|
477
763
|
}
|
|
764
|
+
if (newerIntentArrivedDuringApplication) {
|
|
765
|
+
// Invocation of any host routing callback is the last safe commit boundary. A successful
|
|
766
|
+
// callback may already have navigated, so automatically applying the newly captured token
|
|
767
|
+
// now would create two journeys. Keep it pending and let a fresh SDK lifecycle recover it.
|
|
768
|
+
deterministicTokenDeferredAfterCommittedJourney = state.pending.token;
|
|
769
|
+
}
|
|
478
770
|
const newlyApplied = sourceToken && sourceToken !== link.id ? [link.id, sourceToken] : [link.id];
|
|
479
771
|
const newlyAppliedSet = new Set(newlyApplied);
|
|
480
772
|
const appliedIds = [...state.appliedIds.filter(id => !newlyAppliedSet.has(id)), ...newlyApplied].slice(-MAX_APPLIED_IDS);
|
|
481
|
-
const stillPending = sourceToken !== null
|
|
773
|
+
const stillPending = sourceToken !== null ? state.pending?.token !== sourceToken : newerIntentArrivedDuringApplication;
|
|
482
774
|
state = {
|
|
483
775
|
...state,
|
|
484
776
|
status: stillPending ? 'pending' : 'applied',
|
|
@@ -492,7 +784,9 @@ function createPulseLinkClient(options) {
|
|
|
492
784
|
accountRetryAt = 0;
|
|
493
785
|
}
|
|
494
786
|
emitOutcome(link, 'action_applied');
|
|
495
|
-
if (stillPending
|
|
787
|
+
if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
|
|
788
|
+
void process();
|
|
789
|
+
}
|
|
496
790
|
return true;
|
|
497
791
|
};
|
|
498
792
|
const resolvePublic = async (token, basis) => {
|
|
@@ -529,8 +823,13 @@ function createPulseLinkClient(options) {
|
|
|
529
823
|
};
|
|
530
824
|
const processOnce = async () => {
|
|
531
825
|
if (disposed) return;
|
|
826
|
+
// `capture()` can re-enter while any host callback is awaiting. Never start another routing
|
|
827
|
+
// application concurrently, and never auto-drain the exact token preserved after an already
|
|
828
|
+
// committed journey in this client lifecycle.
|
|
829
|
+
if (routingApplicationInFlight) return;
|
|
532
830
|
const pending = state.pending;
|
|
533
831
|
if (pending) {
|
|
832
|
+
if (pending.token === deterministicTokenDeferredAfterCommittedJourney) return;
|
|
534
833
|
if (pending.nextRetryAt > now()) return;
|
|
535
834
|
if (now() - pending.receivedAt > tokenMaxAgeMs) {
|
|
536
835
|
clearPendingAsTerminal();
|
|
@@ -557,7 +856,7 @@ function createPulseLinkClient(options) {
|
|
|
557
856
|
return;
|
|
558
857
|
}
|
|
559
858
|
try {
|
|
560
|
-
const raw = await options.accountBridge.claim(token);
|
|
859
|
+
const raw = await withPromiseTimeout(() => options.accountBridge.claim(token), requestTimeoutMs, 'account claim');
|
|
561
860
|
if (disposed || state.pending?.token !== token) return;
|
|
562
861
|
const normalizedClaim = normalizeResolved(raw, token, 'account_bound', allowedActions, now());
|
|
563
862
|
const claimed = normalizedClaim ? {
|
|
@@ -566,7 +865,10 @@ function createPulseLinkClient(options) {
|
|
|
566
865
|
matchGuaranteed: true,
|
|
567
866
|
confidence: 1
|
|
568
867
|
} : null;
|
|
569
|
-
if (claimed)
|
|
868
|
+
if (claimed) {
|
|
869
|
+
blockFirstOpenForDeterministic();
|
|
870
|
+
await applyResolved(claimed, token);
|
|
871
|
+
} else clearPendingAsTerminal();
|
|
570
872
|
} catch (error) {
|
|
571
873
|
reportError(error);
|
|
572
874
|
if (state.pending?.token === token) schedulePendingRetry();
|
|
@@ -574,8 +876,13 @@ function createPulseLinkClient(options) {
|
|
|
574
876
|
return;
|
|
575
877
|
}
|
|
576
878
|
if (!options.accountBridge || !isAccountReady() || accountRetryAt > now()) return;
|
|
879
|
+
const accountPendingEpoch = deterministicEpoch;
|
|
577
880
|
try {
|
|
578
|
-
const raw = await options.accountBridge.pending();
|
|
881
|
+
const raw = await withPromiseTimeout(() => options.accountBridge.pending(), requestTimeoutMs, 'account pending');
|
|
882
|
+
// A URL/paste/referrer captured while the account lookup was in flight is newer explicit
|
|
883
|
+
// user intent. Ignore the stale bridge response and let the requested drain resolve the
|
|
884
|
+
// captured token; otherwise both deterministic destinations could be applied.
|
|
885
|
+
if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
579
886
|
if (!raw) {
|
|
580
887
|
accountAttempts = 0;
|
|
581
888
|
accountRetryAt = 0;
|
|
@@ -594,14 +901,21 @@ function createPulseLinkClient(options) {
|
|
|
594
901
|
matchGuaranteed: true,
|
|
595
902
|
confidence: 1
|
|
596
903
|
} : null;
|
|
597
|
-
if (pendingLink
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
904
|
+
if (pendingLink) {
|
|
905
|
+
// Reserve the first-open journey for deterministic recovery before invoking any host
|
|
906
|
+
// callback. This also covers an already-applied account item restored from older SDK
|
|
907
|
+
// state, which must still prevent a second probabilistic destination.
|
|
908
|
+
blockFirstOpenForDeterministic();
|
|
909
|
+
if (!state.appliedIds.includes(pendingLink.id)) {
|
|
910
|
+
await applyResolved(pendingLink, null);
|
|
911
|
+
} else if (state.status !== 'applied') {
|
|
912
|
+
patchState({
|
|
913
|
+
status: 'applied'
|
|
914
|
+
});
|
|
915
|
+
}
|
|
603
916
|
}
|
|
604
917
|
} catch (error) {
|
|
918
|
+
if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
605
919
|
reportError(error);
|
|
606
920
|
// No token was consumed; the authenticated server outbox remains authoritative.
|
|
607
921
|
scheduleAccountRetry();
|
|
@@ -609,6 +923,8 @@ function createPulseLinkClient(options) {
|
|
|
609
923
|
};
|
|
610
924
|
const process = async () => {
|
|
611
925
|
if (disposed) return;
|
|
926
|
+
// Application delivery is independent: never await it on the routing drain.
|
|
927
|
+
void flushTerminalDelivery();
|
|
612
928
|
processRequested = true;
|
|
613
929
|
if (processing) return processing;
|
|
614
930
|
const run = (async () => {
|
|
@@ -632,6 +948,7 @@ function createPulseLinkClient(options) {
|
|
|
632
948
|
const token = normalizeDeferredHandoffToken(rawToken);
|
|
633
949
|
if (!token) return false;
|
|
634
950
|
if (state.appliedIds.includes(token)) {
|
|
951
|
+
deterministicEpoch += 1;
|
|
635
952
|
if (!state.firstOpen.completed) patchFirstOpen({
|
|
636
953
|
completed: true,
|
|
637
954
|
nextRetryAt: 0
|
|
@@ -639,7 +956,8 @@ function createPulseLinkClient(options) {
|
|
|
639
956
|
return true;
|
|
640
957
|
}
|
|
641
958
|
if (state.pending?.token === token) {
|
|
642
|
-
|
|
959
|
+
deterministicEpoch += 1;
|
|
960
|
+
if (CAPTURE_PRIORITY[basis] > CAPTURE_PRIORITY[state.pending.matchBasis]) {
|
|
643
961
|
state = {
|
|
644
962
|
...state,
|
|
645
963
|
pending: {
|
|
@@ -662,6 +980,20 @@ function createPulseLinkClient(options) {
|
|
|
662
980
|
void process();
|
|
663
981
|
return true;
|
|
664
982
|
}
|
|
983
|
+
|
|
984
|
+
// Exact transports are deterministic, but when more than one arrives during cold start their
|
|
985
|
+
// provenance still has an explicit precedence. A lower-priority late callback must not replace
|
|
986
|
+
// the URL the user intentionally opened; equal priority keeps last-touch behaviour.
|
|
987
|
+
if (state.pending && CAPTURE_PRIORITY[basis] < CAPTURE_PRIORITY[state.pending.matchBasis]) {
|
|
988
|
+
deterministicEpoch += 1;
|
|
989
|
+
if (!state.firstOpen.completed) patchFirstOpen({
|
|
990
|
+
completed: true,
|
|
991
|
+
nextRetryAt: 0
|
|
992
|
+
});
|
|
993
|
+
void process();
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
996
|
+
deterministicEpoch += 1;
|
|
665
997
|
state = {
|
|
666
998
|
...state,
|
|
667
999
|
status: 'pending',
|
|
@@ -682,14 +1014,27 @@ function createPulseLinkClient(options) {
|
|
|
682
1014
|
void process();
|
|
683
1015
|
return true;
|
|
684
1016
|
};
|
|
685
|
-
const
|
|
1017
|
+
const matchFirstOpenOnce = async context => {
|
|
686
1018
|
if (disposed) return 'ineligible';
|
|
687
1019
|
lastFirstOpenContext = context;
|
|
688
1020
|
if (state.pending) return 'deterministic_pending';
|
|
689
1021
|
if (state.firstOpen.completed) return 'already_completed';
|
|
690
1022
|
if (state.firstOpen.nextRetryAt > now()) return 'backoff';
|
|
691
1023
|
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
1024
|
+
|
|
1025
|
+
// The client starts account recovery at construction. Join that deterministic rail before
|
|
1026
|
+
// asking the probabilistic matcher; otherwise a slow Billing/account response can navigate
|
|
1027
|
+
// after the probabilistic destination and the user observes both journeys.
|
|
1028
|
+
await process();
|
|
1029
|
+
if (disposed) return 'ineligible';
|
|
1030
|
+
if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1031
|
+
if (options.accountBridge && isAccountReady() && accountRetryAt > now()) {
|
|
1032
|
+
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
1033
|
+
return 'retry_scheduled';
|
|
1034
|
+
}
|
|
1035
|
+
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
692
1036
|
const installAttemptId = state.firstOpen.installAttemptId ?? makeInstallAttemptId(options.randomUUID);
|
|
1037
|
+
const startingDeterministicEpoch = deterministicEpoch;
|
|
693
1038
|
state = {
|
|
694
1039
|
...state,
|
|
695
1040
|
status: 'resolving',
|
|
@@ -717,26 +1062,23 @@ function createPulseLinkClient(options) {
|
|
|
717
1062
|
},
|
|
718
1063
|
body: JSON.stringify(body)
|
|
719
1064
|
}, requestTimeoutMs);
|
|
720
|
-
if (
|
|
721
|
-
if (state.firstOpen.completed) return 'deterministic_pending';
|
|
1065
|
+
if (disposed) return 'ineligible';
|
|
1066
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
722
1067
|
if (response.status === 204) {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
patchState({
|
|
729
|
-
status: 'idle'
|
|
1068
|
+
completeFirstOpen('idle', {
|
|
1069
|
+
status: 'NOT_FOUND',
|
|
1070
|
+
rail: 'no_route',
|
|
1071
|
+
routed: false,
|
|
1072
|
+
reason: firstOpenNoRouteReason(response)
|
|
730
1073
|
});
|
|
731
1074
|
return 'no_match';
|
|
732
1075
|
}
|
|
733
|
-
if (response.status === 400 || response.status === 404 || response.status === 410) {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
status: 'terminal_error'
|
|
1076
|
+
if (response.status === 400 || response.status === 404 || response.status === 410 || response.status === 413) {
|
|
1077
|
+
completeFirstOpen('terminal_error', {
|
|
1078
|
+
status: 'FAILURE',
|
|
1079
|
+
rail: 'no_route',
|
|
1080
|
+
routed: false,
|
|
1081
|
+
reason: 'invalid_request'
|
|
740
1082
|
});
|
|
741
1083
|
return 'terminal_error';
|
|
742
1084
|
}
|
|
@@ -745,44 +1087,95 @@ function createPulseLinkClient(options) {
|
|
|
745
1087
|
scheduleFirstOpenRetry();
|
|
746
1088
|
return 'retry_scheduled';
|
|
747
1089
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1090
|
+
completeFirstOpen('terminal_error', {
|
|
1091
|
+
status: 'FAILURE',
|
|
1092
|
+
rail: 'no_route',
|
|
1093
|
+
routed: false,
|
|
1094
|
+
reason: 'invalid_response'
|
|
751
1095
|
});
|
|
752
|
-
|
|
753
|
-
|
|
1096
|
+
return 'terminal_error';
|
|
1097
|
+
}
|
|
1098
|
+
let rawLink;
|
|
1099
|
+
try {
|
|
1100
|
+
rawLink = await response.json();
|
|
1101
|
+
} catch {
|
|
1102
|
+
completeFirstOpen('terminal_error', {
|
|
1103
|
+
status: 'FAILURE',
|
|
1104
|
+
rail: 'no_route',
|
|
1105
|
+
routed: false,
|
|
1106
|
+
reason: 'invalid_response'
|
|
754
1107
|
});
|
|
755
1108
|
return 'terminal_error';
|
|
756
1109
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
if (!link
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1110
|
+
if (disposed) return 'ineligible';
|
|
1111
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1112
|
+
const link = normalizeResolved(rawLink, installAttemptId.replace(/-/g, ''), 'unique_probabilistic', allowedActions, now());
|
|
1113
|
+
if (!link) {
|
|
1114
|
+
completeFirstOpen('terminal_error', {
|
|
1115
|
+
status: 'FAILURE',
|
|
1116
|
+
rail: 'no_route',
|
|
1117
|
+
routed: false,
|
|
1118
|
+
reason: 'invalid_response'
|
|
764
1119
|
});
|
|
765
|
-
|
|
766
|
-
|
|
1120
|
+
return 'terminal_error';
|
|
1121
|
+
}
|
|
1122
|
+
// A probabilistic result is navigation intent, never a remote URL transport. Only the two
|
|
1123
|
+
// harmless closed discovery actions are accepted; all billing/account/entitlement actions
|
|
1124
|
+
// and even an app-owned deepLink are rejected before application code runs.
|
|
1125
|
+
if (link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed || link.deepLink !== undefined || !link.action || !PROBABILISTIC_ACTIONS.has(String(link.action)) || sensitiveActions.has(link.action) || !options.onAction) {
|
|
1126
|
+
completeFirstOpen('terminal_error', {
|
|
1127
|
+
status: 'FAILURE',
|
|
1128
|
+
rail: 'no_route',
|
|
1129
|
+
routed: false,
|
|
1130
|
+
reason: 'policy_rejected'
|
|
767
1131
|
});
|
|
768
1132
|
return 'terminal_error';
|
|
769
1133
|
}
|
|
1134
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
770
1135
|
if (await applyResolved(link, null)) {
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1136
|
+
// `applyResolved()` returns true only after the host callback crossed its irreversible
|
|
1137
|
+
// commit boundary successfully. A deterministic capture can arrive while that callback
|
|
1138
|
+
// awaits; it remains pending for the next lifecycle, but it must not erase the terminal
|
|
1139
|
+
// truth that this probabilistic journey really routed.
|
|
1140
|
+
completeFirstOpen(state.pending ? 'pending' : 'applied', {
|
|
1141
|
+
status: 'FOUND',
|
|
1142
|
+
rail: 'fast_route',
|
|
1143
|
+
routed: true,
|
|
1144
|
+
reason: 'matched',
|
|
1145
|
+
matchBasis: link.matchBasis,
|
|
1146
|
+
confidence: link.confidence,
|
|
1147
|
+
...(link.campaignId !== undefined ? {
|
|
1148
|
+
campaignId: link.campaignId
|
|
1149
|
+
} : {}),
|
|
1150
|
+
...(link.experimentId !== undefined ? {
|
|
1151
|
+
experimentId: link.experimentId
|
|
1152
|
+
} : {}),
|
|
1153
|
+
...(link.variantId !== undefined ? {
|
|
1154
|
+
variantId: link.variantId
|
|
1155
|
+
} : {})
|
|
775
1156
|
});
|
|
776
1157
|
return 'matched';
|
|
777
1158
|
}
|
|
1159
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
778
1160
|
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
779
1161
|
return 'retry_scheduled';
|
|
780
1162
|
} catch (error) {
|
|
1163
|
+
if (disposed) return 'ineligible';
|
|
781
1164
|
reportError(error);
|
|
1165
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
782
1166
|
scheduleFirstOpenRetry();
|
|
783
1167
|
return 'retry_scheduled';
|
|
784
1168
|
}
|
|
785
1169
|
};
|
|
1170
|
+
const matchFirstOpen = context => {
|
|
1171
|
+
if (firstOpenMatching) return firstOpenMatching;
|
|
1172
|
+
let owned;
|
|
1173
|
+
owned = matchFirstOpenOnce(context).finally(() => {
|
|
1174
|
+
if (firstOpenMatching === owned) firstOpenMatching = null;
|
|
1175
|
+
});
|
|
1176
|
+
firstOpenMatching = owned;
|
|
1177
|
+
return owned;
|
|
1178
|
+
};
|
|
786
1179
|
const captureAndroidInstallReferrer = async bridge => {
|
|
787
1180
|
let raw;
|
|
788
1181
|
try {
|
|
@@ -807,13 +1200,14 @@ function createPulseLinkClient(options) {
|
|
|
807
1200
|
retryTimer = null;
|
|
808
1201
|
}
|
|
809
1202
|
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());
|
|
1203
|
+
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
1204
|
if (candidates.length === 0) return;
|
|
812
1205
|
const next = Math.min(...candidates);
|
|
813
1206
|
retryTimer = setTimeout(() => {
|
|
814
1207
|
retryTimer = null;
|
|
815
1208
|
void process();
|
|
816
1209
|
void flushResolverOutcomes();
|
|
1210
|
+
void flushTerminalDelivery();
|
|
817
1211
|
if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
|
|
818
1212
|
void matchFirstOpen(lastFirstOpenContext);
|
|
819
1213
|
}
|
|
@@ -833,6 +1227,7 @@ function createPulseLinkClient(options) {
|
|
|
833
1227
|
scheduleWake();
|
|
834
1228
|
void process();
|
|
835
1229
|
void flushResolverOutcomes();
|
|
1230
|
+
void flushTerminalDelivery();
|
|
836
1231
|
return {
|
|
837
1232
|
capture,
|
|
838
1233
|
captureUrl: url => {
|
|
@@ -850,6 +1245,8 @@ function createPulseLinkClient(options) {
|
|
|
850
1245
|
dispose: () => {
|
|
851
1246
|
if (disposed) return;
|
|
852
1247
|
disposed = true;
|
|
1248
|
+
deterministicEpoch += 1;
|
|
1249
|
+
terminalDeliveryGeneration += 1;
|
|
853
1250
|
if (retryTimer) clearTimeout(retryTimer);
|
|
854
1251
|
retryTimer = null;
|
|
855
1252
|
unsubscribeAccount?.();
|
|
@@ -859,6 +1256,10 @@ function createPulseLinkClient(options) {
|
|
|
859
1256
|
reset: () => {
|
|
860
1257
|
accountRetryAt = 0;
|
|
861
1258
|
accountAttempts = 0;
|
|
1259
|
+
deterministicEpoch += 1;
|
|
1260
|
+
terminalDeliveryGeneration += 1;
|
|
1261
|
+
terminalDeliverySending = null;
|
|
1262
|
+
deterministicTokenDeferredAfterCommittedJourney = null;
|
|
862
1263
|
state = emptyState();
|
|
863
1264
|
persistAndNotify();
|
|
864
1265
|
}
|
|
@@ -902,11 +1303,11 @@ function stableOutcomeKey(linkId, name) {
|
|
|
902
1303
|
}
|
|
903
1304
|
return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}|${name}`;
|
|
904
1305
|
}
|
|
905
|
-
function boundedMetadata(value) {
|
|
1306
|
+
function boundedMetadata(value, maxLength = 256) {
|
|
906
1307
|
if (value === null) return null;
|
|
907
1308
|
if (typeof value !== 'string' && typeof value !== 'number') return undefined;
|
|
908
1309
|
const result = String(value).trim();
|
|
909
|
-
return result.length > 0 && result.length <=
|
|
1310
|
+
return result.length > 0 && result.length <= maxLength ? result : undefined;
|
|
910
1311
|
}
|
|
911
1312
|
function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now) {
|
|
912
1313
|
if (!raw || typeof raw !== 'object') return null;
|
|
@@ -928,9 +1329,9 @@ function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now)
|
|
|
928
1329
|
if (!Number.isFinite(expiry) || expiry <= now) return null;
|
|
929
1330
|
}
|
|
930
1331
|
const source = boundedMetadata(value.source);
|
|
931
|
-
const campaignId = boundedMetadata(value.campaignId);
|
|
932
|
-
const experimentId = boundedMetadata(value.experimentId);
|
|
933
|
-
const variantId = boundedMetadata(value.variantId);
|
|
1332
|
+
const campaignId = boundedMetadata(value.campaignId, 128);
|
|
1333
|
+
const experimentId = boundedMetadata(value.experimentId, 128);
|
|
1334
|
+
const variantId = boundedMetadata(value.variantId, 128);
|
|
934
1335
|
return {
|
|
935
1336
|
id,
|
|
936
1337
|
...(action ? {
|
|
@@ -967,6 +1368,15 @@ function isEligibleFirstOpen(context, now, maxAgeMs) {
|
|
|
967
1368
|
function normalizeLocale(locale) {
|
|
968
1369
|
return locale.trim().replace(/_/g, '-');
|
|
969
1370
|
}
|
|
1371
|
+
function firstOpenNoRouteReason(response) {
|
|
1372
|
+
let raw = '';
|
|
1373
|
+
try {
|
|
1374
|
+
raw = response.headers?.get('X-Encore-Match-Outcome')?.trim().toLowerCase() ?? '';
|
|
1375
|
+
} catch {
|
|
1376
|
+
raw = '';
|
|
1377
|
+
}
|
|
1378
|
+
return NO_ROUTE_REASONS.has(raw) ? raw : 'unmatched';
|
|
1379
|
+
}
|
|
970
1380
|
function normalizeAnonymousFirstOpenSignals(context) {
|
|
971
1381
|
const appVersion = normalizeSignalString(context.appVersion, VERSION_SIGNAL, 64);
|
|
972
1382
|
const osVersion = normalizeSignalString(context.osVersion, VERSION_SIGNAL, 64);
|
|
@@ -1058,6 +1468,31 @@ async function withTimeout(fetcher, input, init, timeoutMs) {
|
|
|
1058
1468
|
clearTimeout(timer);
|
|
1059
1469
|
}
|
|
1060
1470
|
}
|
|
1471
|
+
async function withPromiseTimeout(factory, timeoutMs, operation) {
|
|
1472
|
+
let timer = null;
|
|
1473
|
+
try {
|
|
1474
|
+
return await Promise.race([Promise.resolve().then(factory), new Promise((_resolve, reject) => {
|
|
1475
|
+
timer = setTimeout(() => reject(new Error(`Pulse Links: ${operation} timed out`)), timeoutMs);
|
|
1476
|
+
})]);
|
|
1477
|
+
} finally {
|
|
1478
|
+
if (timer) clearTimeout(timer);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
async function withTerminalDeliveryTimeout(acknowledgement, timeoutMs) {
|
|
1482
|
+
let timer = null;
|
|
1483
|
+
try {
|
|
1484
|
+
return await Promise.race([acknowledgement, new Promise((_resolve, reject) => {
|
|
1485
|
+
timer = setTimeout(() => reject(new Error('Pulse Links: terminal delivery acknowledgement timed out')), timeoutMs);
|
|
1486
|
+
})]);
|
|
1487
|
+
} finally {
|
|
1488
|
+
if (timer) clearTimeout(timer);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
function normalizeTerminalDeliveryDisposition(value) {
|
|
1492
|
+
if (value === true || value === 'accepted') return 'accepted';
|
|
1493
|
+
if (value === 'drop') return 'drop';
|
|
1494
|
+
return 'retry';
|
|
1495
|
+
}
|
|
1061
1496
|
async function postResolverOutcome(fetcher, resolverBaseUrl, outcome, timeoutMs) {
|
|
1062
1497
|
return withTimeout(fetcher, `${resolverBaseUrl}${encodeURIComponent(outcome.token)}/event/${outcome.name}`, {
|
|
1063
1498
|
method: 'POST',
|
|
@@ -1124,6 +1559,56 @@ function normalizeOccurredAt(value) {
|
|
|
1124
1559
|
if (!Number.isFinite(timestamp)) return null;
|
|
1125
1560
|
return new Date(timestamp).toISOString() === value ? value : null;
|
|
1126
1561
|
}
|
|
1562
|
+
function readTerminalDelivery(raw) {
|
|
1563
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
1564
|
+
const value = raw;
|
|
1565
|
+
const eventId = typeof value.eventId === 'string' && UUID_V4.test(value.eventId) ? value.eventId.toLowerCase() : null;
|
|
1566
|
+
const status = value.status === 'FOUND' || value.status === 'NOT_FOUND' || value.status === 'FAILURE' ? value.status : null;
|
|
1567
|
+
const rail = value.rail === 'fast_route' || value.rail === 'no_route' ? value.rail : null;
|
|
1568
|
+
const reason = typeof value.reason === 'string' ? value.reason : null;
|
|
1569
|
+
const occurredAt = normalizeOccurredAt(value.occurredAt);
|
|
1570
|
+
const attempts = typeof value.attempts === 'number' && Number.isSafeInteger(value.attempts) && value.attempts >= 0 && value.attempts <= 100_000 ? value.attempts : null;
|
|
1571
|
+
const nextRetryAt = typeof value.nextRetryAt === 'number' && Number.isSafeInteger(value.nextRetryAt) && value.nextRetryAt >= 0 && value.nextRetryAt <= 8_640_000_000_000_000 ? value.nextRetryAt : null;
|
|
1572
|
+
if (value.disposition === 'drop') {
|
|
1573
|
+
const allowedKeys = new Set(['eventId', 'disposition', 'attempts', 'nextRetryAt']);
|
|
1574
|
+
if (!eventId || attempts === null || nextRetryAt === null || Object.keys(value).some(key => !allowedKeys.has(key))) return null;
|
|
1575
|
+
return {
|
|
1576
|
+
eventId,
|
|
1577
|
+
disposition: 'drop',
|
|
1578
|
+
attempts,
|
|
1579
|
+
nextRetryAt
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
const matchBasis = typeof value.matchBasis === 'string' && MATCH_BASES.has(value.matchBasis) ? value.matchBasis : null;
|
|
1583
|
+
const confidence = typeof value.confidence === 'number' && Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : null;
|
|
1584
|
+
const metadata = {};
|
|
1585
|
+
let metadataValid = true;
|
|
1586
|
+
for (const key of ['campaignId', 'experimentId', 'variantId']) {
|
|
1587
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
|
|
1588
|
+
const field = value[key];
|
|
1589
|
+
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;
|
|
1590
|
+
}
|
|
1591
|
+
const hasFoundDimensions = Object.prototype.hasOwnProperty.call(value, 'matchBasis') && Object.prototype.hasOwnProperty.call(value, 'confidence');
|
|
1592
|
+
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');
|
|
1593
|
+
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;
|
|
1594
|
+
if (!eventId || !status || !rail || !reason || !occurredAt || attempts === null || nextRetryAt === null || Object.prototype.hasOwnProperty.call(value, 'disposition') || value.retryable !== false || !validSemanticOutcome) return null;
|
|
1595
|
+
return {
|
|
1596
|
+
eventId,
|
|
1597
|
+
status,
|
|
1598
|
+
rail,
|
|
1599
|
+
routed: value.routed,
|
|
1600
|
+
retryable: false,
|
|
1601
|
+
reason,
|
|
1602
|
+
occurredAt,
|
|
1603
|
+
...(status === 'FOUND' ? {
|
|
1604
|
+
matchBasis: matchBasis,
|
|
1605
|
+
confidence: confidence,
|
|
1606
|
+
...metadata
|
|
1607
|
+
} : {}),
|
|
1608
|
+
attempts,
|
|
1609
|
+
nextRetryAt
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1127
1612
|
function readState(storage, key) {
|
|
1128
1613
|
if (!storage) return emptyState();
|
|
1129
1614
|
try {
|
|
@@ -1150,6 +1635,7 @@ function readState(storage, key) {
|
|
|
1150
1635
|
const attemptId = typeof firstValue.installAttemptId === 'string' && INSTALL_ATTEMPT_ID.test(firstValue.installAttemptId) ? firstValue.installAttemptId.toLowerCase() : null;
|
|
1151
1636
|
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
1637
|
const outcomeQueue = readResolverOutcomeQueue(parsed.outcomeQueue);
|
|
1638
|
+
const terminalDelivery = readTerminalDelivery(parsed.terminalDelivery);
|
|
1153
1639
|
return {
|
|
1154
1640
|
version: 1,
|
|
1155
1641
|
status: status === 'resolving' ? pending ? 'pending' : 'idle' : status,
|
|
@@ -1158,6 +1644,7 @@ function readState(storage, key) {
|
|
|
1158
1644
|
appliedIds: [...new Set(appliedIds)].slice(-MAX_APPLIED_IDS),
|
|
1159
1645
|
notifiedOutcomes: [...new Set([...notifiedOutcomes, ...outcomeQueue.map(outcome => outcome.transitionKey)])].slice(-MAX_NOTIFIED_OUTCOMES),
|
|
1160
1646
|
outcomeQueue,
|
|
1647
|
+
terminalDelivery,
|
|
1161
1648
|
firstOpen: {
|
|
1162
1649
|
installAttemptId: attemptId,
|
|
1163
1650
|
completed: firstValue.completed === true,
|
|
@@ -1170,11 +1657,24 @@ function readState(storage, key) {
|
|
|
1170
1657
|
return emptyState();
|
|
1171
1658
|
}
|
|
1172
1659
|
}
|
|
1173
|
-
function writeState(storage, key, state) {
|
|
1174
|
-
if (!storage) return;
|
|
1660
|
+
function writeState(storage, key, state, allowKnownAsyncBestEffort = true) {
|
|
1661
|
+
if (!storage) return true;
|
|
1662
|
+
const knownAsync = storage.supportsDurableSyncWrites === false || ASYNCHRONOUS_STORAGE_ADAPTERS.has(storage);
|
|
1663
|
+
if (knownAsync && !allowKnownAsyncBestEffort) return false;
|
|
1175
1664
|
try {
|
|
1176
|
-
|
|
1177
|
-
|
|
1665
|
+
const result = storage.set(key, JSON.stringify(state));
|
|
1666
|
+
if (isThenable(result)) {
|
|
1667
|
+
ASYNCHRONOUS_STORAGE_ADAPTERS.add(storage);
|
|
1668
|
+
void Promise.resolve(result).catch(() => undefined);
|
|
1669
|
+
return false;
|
|
1670
|
+
}
|
|
1671
|
+
return !knownAsync;
|
|
1672
|
+
} catch {
|
|
1673
|
+
return false;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
function isThenable(value) {
|
|
1677
|
+
return typeof value === 'object' && value !== null || typeof value === 'function' ? typeof value.then === 'function' : false;
|
|
1178
1678
|
}
|
|
1179
1679
|
function safeTimestamp(value) {
|
|
1180
1680
|
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|