pulse-updates 1.3.7 → 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 +35 -2
- 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 +561 -61
- 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 +561 -61
- 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 +49 -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 +1 -1
- package/src/config.ts +6 -0
- package/src/init.ts +16 -1
- package/src/links.ts +810 -40
- package/src/track.ts +283 -81
package/lib/module/links.js
CHANGED
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
* optional account bridge below.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
/** Explicit receiver decision for one terminal-delivery attempt. */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sanitized terminal result of the anonymous first-open rail. It deliberately carries no
|
|
14
|
+
* install-attempt id, token, device signal or raw server error. A 204 is always `no_route` and
|
|
15
|
+
* can never make the client navigate, including when Encore reports a shadow observation.
|
|
16
|
+
*/
|
|
17
|
+
|
|
10
18
|
/** Implemented by the separate native `PulseAttribution` module. */
|
|
11
19
|
|
|
12
20
|
const OPAQUE_TOKEN = /^[A-Za-z0-9_-]{16,512}$/;
|
|
@@ -23,6 +31,14 @@ const STATUSES = new Set(['idle', 'pending', 'resolving', 'waiting_for_account',
|
|
|
23
31
|
const INSTALL_REFERRER_STATUSES = new Set(['OK', 'NO_TOKEN', 'FEATURE_NOT_SUPPORTED', 'SERVICE_UNAVAILABLE', 'DEVELOPER_ERROR', 'SERVICE_DISCONNECTED']);
|
|
24
32
|
const DEFAULT_ACTIONS = ['open_home', 'open_premium', 'manage_subscription', 'refresh_entitlement'];
|
|
25
33
|
const DEFAULT_SENSITIVE_ACTIONS = ['manage_subscription', 'refresh_entitlement'];
|
|
34
|
+
const PROBABILISTIC_ACTIONS = new Set(['open_home', 'open_premium']);
|
|
35
|
+
const CAPTURE_PRIORITY = {
|
|
36
|
+
direct_token: 3,
|
|
37
|
+
ios_user_paste: 2,
|
|
38
|
+
android_install_referrer: 1
|
|
39
|
+
};
|
|
40
|
+
const NO_ROUTE_REASONS = new Set(['unmatched', 'ambiguous', 'holdout', 'low_confidence', 'shadow_would_route', 'shadow_attributed', 'analytics_attributed', 'target_revoked', 'disabled', 'expired_replay']);
|
|
41
|
+
const FAILURE_REASONS = new Set(['invalid_request', 'invalid_response', 'policy_rejected']);
|
|
26
42
|
const DEFAULT_STORAGE_KEY = 'pulse.links.v1';
|
|
27
43
|
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
28
44
|
const DEFAULT_RETRY_BASE_MS = 15_000;
|
|
@@ -33,10 +49,14 @@ const MAX_APPLIED_IDS = 32;
|
|
|
33
49
|
const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
|
|
34
50
|
const MAX_RESOLVER_OUTCOMES = MAX_NOTIFIED_OUTCOMES;
|
|
35
51
|
const MAX_PERSISTED_BYTES = 131_072;
|
|
52
|
+
const ASYNCHRONOUS_STORAGE_ADAPTERS = new WeakSet();
|
|
36
53
|
const VERSION_SIGNAL = /^[A-Za-z0-9][A-Za-z0-9._+()-]*$/;
|
|
37
54
|
const DEVICE_MODEL_CODE = /^[A-Za-z0-9][A-Za-z0-9._,+-]*$/;
|
|
38
55
|
const DISTRIBUTION_SIGNAL = /^[a-z0-9][a-z0-9._-]*$/;
|
|
39
56
|
const DEVICE_TYPES = new Set(['phone', 'tablet', 'tv', 'desktop', 'gaming_console', 'unknown']);
|
|
57
|
+
|
|
58
|
+
/** Minimal sticky privacy tombstone. It carries no terminal outcome or attribution metadata. */
|
|
59
|
+
|
|
40
60
|
const emptyState = () => ({
|
|
41
61
|
version: 1,
|
|
42
62
|
status: 'idle',
|
|
@@ -45,6 +65,7 @@ const emptyState = () => ({
|
|
|
45
65
|
appliedIds: [],
|
|
46
66
|
notifiedOutcomes: [],
|
|
47
67
|
outcomeQueue: [],
|
|
68
|
+
terminalDelivery: null,
|
|
48
69
|
firstOpen: {
|
|
49
70
|
installAttemptId: null,
|
|
50
71
|
completed: false,
|
|
@@ -145,6 +166,7 @@ export function createPulseLinkClient(options) {
|
|
|
145
166
|
}
|
|
146
167
|
const storageKey = explicitStorageKey || (appSlug ? `pulse.${appSlug}.links.v1` : DEFAULT_STORAGE_KEY);
|
|
147
168
|
const requestTimeoutMs = boundedDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 500, 60_000);
|
|
169
|
+
const terminalDeliveryTimeoutMs = boundedDuration(options.terminalDeliveryTimeoutMs, requestTimeoutMs, 100, 60_000);
|
|
148
170
|
const retryBaseMs = boundedDuration(options.retryBaseMs, DEFAULT_RETRY_BASE_MS, 100, 60 * 60 * 1_000);
|
|
149
171
|
const retryMaxMs = boundedDuration(options.retryMaxMs, DEFAULT_RETRY_MAX_MS, retryBaseMs, 24 * 60 * 60 * 1_000);
|
|
150
172
|
const tokenMaxAgeMs = boundedDuration(options.tokenMaxAgeMs, DEFAULT_TOKEN_MAX_AGE_MS, 60_000, 365 * 24 * 60 * 60 * 1_000);
|
|
@@ -153,11 +175,21 @@ export function createPulseLinkClient(options) {
|
|
|
153
175
|
let state = readState(options.storage, storageKey);
|
|
154
176
|
let disposed = false;
|
|
155
177
|
let processing = null;
|
|
178
|
+
let firstOpenMatching = null;
|
|
156
179
|
let processRequested = false;
|
|
157
180
|
let retryTimer = null;
|
|
158
181
|
let outcomeSending = null;
|
|
182
|
+
let terminalDeliverySending = null;
|
|
183
|
+
let terminalDeliveryGeneration = 0;
|
|
159
184
|
let accountRetryAt = 0;
|
|
160
185
|
let accountAttempts = 0;
|
|
186
|
+
let deterministicEpoch = 0;
|
|
187
|
+
// Host navigation is irreversible once any routing callback has started: it may perform its
|
|
188
|
+
// side effect synchronously before returning a Promise. Serialize captures against that commit
|
|
189
|
+
// boundary and keep the newer accepted token durable for the next client lifecycle instead of
|
|
190
|
+
// automatically opening a second journey behind the first one.
|
|
191
|
+
let routingApplicationInFlight = false;
|
|
192
|
+
let deterministicTokenDeferredAfterCommittedJourney = null;
|
|
161
193
|
let lastFirstOpenContext = null;
|
|
162
194
|
let unsubscribeAccount = null;
|
|
163
195
|
const listeners = new Set();
|
|
@@ -166,6 +198,25 @@ export function createPulseLinkClient(options) {
|
|
|
166
198
|
options.onError?.(error);
|
|
167
199
|
} catch {/* diagnostic hooks never break link handling */}
|
|
168
200
|
};
|
|
201
|
+
|
|
202
|
+
// A build without a receiver has not opted into retaining this analytics envelope. First replace
|
|
203
|
+
// an old full record with the same minimal sticky tombstone used by an explicit drop. Deletion
|
|
204
|
+
// may fail, but a later app version can then only retry local deletion, never resurrect delivery.
|
|
205
|
+
if (!options.onFirstOpenResult && state.terminalDelivery && state.terminalDelivery.disposition !== 'drop') {
|
|
206
|
+
const tombstone = {
|
|
207
|
+
eventId: state.terminalDelivery.eventId,
|
|
208
|
+
disposition: 'drop',
|
|
209
|
+
attempts: state.terminalDelivery.attempts,
|
|
210
|
+
nextRetryAt: 0
|
|
211
|
+
};
|
|
212
|
+
state = {
|
|
213
|
+
...state,
|
|
214
|
+
terminalDelivery: tombstone
|
|
215
|
+
};
|
|
216
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
217
|
+
reportError(new Error('Pulse Links: stale terminal delivery tombstone was not persisted'));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
169
220
|
const snapshot = () => ({
|
|
170
221
|
status: state.status,
|
|
171
222
|
pending: state.pending ? {
|
|
@@ -177,8 +228,7 @@ export function createPulseLinkClient(options) {
|
|
|
177
228
|
...state.firstOpen
|
|
178
229
|
}
|
|
179
230
|
});
|
|
180
|
-
const
|
|
181
|
-
writeState(options.storage, storageKey, state);
|
|
231
|
+
const notifyState = () => {
|
|
182
232
|
const value = snapshot();
|
|
183
233
|
for (const listener of listeners) {
|
|
184
234
|
try {
|
|
@@ -194,6 +244,11 @@ export function createPulseLinkClient(options) {
|
|
|
194
244
|
}
|
|
195
245
|
scheduleWake();
|
|
196
246
|
};
|
|
247
|
+
const persistAndNotify = () => {
|
|
248
|
+
const persisted = writeState(options.storage, storageKey, state);
|
|
249
|
+
notifyState();
|
|
250
|
+
return persisted;
|
|
251
|
+
};
|
|
197
252
|
const patchState = patch => {
|
|
198
253
|
state = {
|
|
199
254
|
...state,
|
|
@@ -211,6 +266,205 @@ export function createPulseLinkClient(options) {
|
|
|
211
266
|
};
|
|
212
267
|
persistAndNotify();
|
|
213
268
|
};
|
|
269
|
+
const terminalDeliveryOutcome = queued => ({
|
|
270
|
+
eventId: queued.eventId,
|
|
271
|
+
status: queued.status,
|
|
272
|
+
rail: queued.rail,
|
|
273
|
+
routed: queued.routed,
|
|
274
|
+
retryable: false,
|
|
275
|
+
reason: queued.reason,
|
|
276
|
+
occurredAt: queued.occurredAt,
|
|
277
|
+
...(queued.matchBasis !== undefined ? {
|
|
278
|
+
matchBasis: queued.matchBasis
|
|
279
|
+
} : {}),
|
|
280
|
+
...(queued.confidence !== undefined ? {
|
|
281
|
+
confidence: queued.confidence
|
|
282
|
+
} : {}),
|
|
283
|
+
...(queued.campaignId !== undefined ? {
|
|
284
|
+
campaignId: queued.campaignId
|
|
285
|
+
} : {}),
|
|
286
|
+
...(queued.experimentId !== undefined ? {
|
|
287
|
+
experimentId: queued.experimentId
|
|
288
|
+
} : {}),
|
|
289
|
+
...(queued.variantId !== undefined ? {
|
|
290
|
+
variantId: queued.variantId
|
|
291
|
+
} : {})
|
|
292
|
+
});
|
|
293
|
+
const scheduleTerminalDeliveryRetry = queued => {
|
|
294
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return;
|
|
295
|
+
const attempts = Math.min(queued.attempts + 1, 100_000);
|
|
296
|
+
state = {
|
|
297
|
+
...state,
|
|
298
|
+
terminalDelivery: {
|
|
299
|
+
...queued,
|
|
300
|
+
attempts,
|
|
301
|
+
nextRetryAt: now() + resolverOutcomeRetryDelay(queued.eventId, attempts, retryBaseMs, retryMaxMs)
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
persistAndNotify();
|
|
305
|
+
};
|
|
306
|
+
const acknowledgeTerminalDelivery = queued => {
|
|
307
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
308
|
+
const acknowledgedState = {
|
|
309
|
+
...state,
|
|
310
|
+
terminalDelivery: null
|
|
311
|
+
};
|
|
312
|
+
// Clearing the record is itself transactional. If this write fails after the receiver
|
|
313
|
+
// accepted the event, retain and replay the same eventId: that is why the contract is
|
|
314
|
+
// at-least-once and why receivers must deduplicate.
|
|
315
|
+
if (!writeState(options.storage, storageKey, acknowledgedState, false)) {
|
|
316
|
+
reportError(new Error('Pulse Links: terminal delivery acknowledgement was not persisted'));
|
|
317
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
state = acknowledgedState;
|
|
321
|
+
notifyState();
|
|
322
|
+
return true;
|
|
323
|
+
};
|
|
324
|
+
const flushTerminalDelivery = () => {
|
|
325
|
+
if (disposed) return Promise.resolve(false);
|
|
326
|
+
if (terminalDeliverySending) return terminalDeliverySending;
|
|
327
|
+
const queued = state.terminalDelivery;
|
|
328
|
+
if (!queued || queued.nextRetryAt > now()) return Promise.resolve(false);
|
|
329
|
+
if (queued.disposition === 'drop') {
|
|
330
|
+
return Promise.resolve(acknowledgeTerminalDelivery(queued));
|
|
331
|
+
}
|
|
332
|
+
if (!options.onFirstOpenResult) return Promise.resolve(false);
|
|
333
|
+
const generation = terminalDeliveryGeneration;
|
|
334
|
+
const run = async () => {
|
|
335
|
+
// Never call the receiver before the complete record is durable. With no storage adapter,
|
|
336
|
+
// this is an explicitly memory-only degradation rather than a cross-restart guarantee.
|
|
337
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
338
|
+
reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
|
|
339
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
let rawDisposition;
|
|
343
|
+
try {
|
|
344
|
+
rawDisposition = options.onFirstOpenResult(terminalDeliveryOutcome(queued));
|
|
345
|
+
} catch (error) {
|
|
346
|
+
reportError(error);
|
|
347
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
let disposition;
|
|
351
|
+
if (typeof rawDisposition === 'boolean' || typeof rawDisposition === 'string') {
|
|
352
|
+
disposition = normalizeTerminalDeliveryDisposition(rawDisposition);
|
|
353
|
+
} else {
|
|
354
|
+
try {
|
|
355
|
+
const resolved = await withTerminalDeliveryTimeout(Promise.resolve(rawDisposition), terminalDeliveryTimeoutMs);
|
|
356
|
+
disposition = normalizeTerminalDeliveryDisposition(resolved);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (!disposed && terminalDeliveryGeneration === generation) reportError(error);
|
|
359
|
+
if (!disposed && terminalDeliveryGeneration === generation) {
|
|
360
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
361
|
+
}
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (disposed || terminalDeliveryGeneration !== generation || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
366
|
+
if (disposition === 'accepted') return acknowledgeTerminalDelivery(queued);
|
|
367
|
+
if (disposition === 'drop') return dropTerminalDelivery(queued);
|
|
368
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
369
|
+
return false;
|
|
370
|
+
};
|
|
371
|
+
let owned;
|
|
372
|
+
owned = run().finally(() => {
|
|
373
|
+
if (terminalDeliverySending === owned) terminalDeliverySending = null;
|
|
374
|
+
const pending = state.terminalDelivery;
|
|
375
|
+
if (!disposed && options.onFirstOpenResult && pending && pending.nextRetryAt <= now()) {
|
|
376
|
+
void flushTerminalDelivery();
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
terminalDeliverySending = owned;
|
|
380
|
+
return owned;
|
|
381
|
+
};
|
|
382
|
+
const dropTerminalDelivery = queued => {
|
|
383
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
384
|
+
const tombstone = {
|
|
385
|
+
eventId: queued.eventId,
|
|
386
|
+
disposition: 'drop',
|
|
387
|
+
attempts: queued.attempts,
|
|
388
|
+
nextRetryAt: 0
|
|
389
|
+
};
|
|
390
|
+
state = {
|
|
391
|
+
...state,
|
|
392
|
+
terminalDelivery: tombstone
|
|
393
|
+
};
|
|
394
|
+
// Persist the sticky disposition before trying to delete it. A failed local deletion can then
|
|
395
|
+
// retry only deletion after restart; it must never call the receiver or resurrect collection.
|
|
396
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
397
|
+
reportError(new Error('Pulse Links: terminal drop tombstone was not persisted'));
|
|
398
|
+
scheduleTerminalDeliveryRetry(tombstone);
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
notifyState();
|
|
402
|
+
return acknowledgeTerminalDelivery(tombstone);
|
|
403
|
+
};
|
|
404
|
+
const completeFirstOpen = (status, outcome) => {
|
|
405
|
+
let shouldQueue = Boolean(options.onFirstOpenResult);
|
|
406
|
+
if (shouldQueue && options.shouldQueueFirstOpenResult) {
|
|
407
|
+
try {
|
|
408
|
+
shouldQueue = options.shouldQueueFirstOpenResult() === true;
|
|
409
|
+
} catch (error) {
|
|
410
|
+
shouldQueue = false;
|
|
411
|
+
reportError(error);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (!shouldQueue) {
|
|
415
|
+
state = {
|
|
416
|
+
...state,
|
|
417
|
+
status,
|
|
418
|
+
terminalDelivery: null,
|
|
419
|
+
firstOpen: {
|
|
420
|
+
...state.firstOpen,
|
|
421
|
+
completed: true,
|
|
422
|
+
attempts: 0,
|
|
423
|
+
nextRetryAt: 0
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
persistAndNotify();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const existingEventIds = new Set([...state.outcomeQueue.map(queued => queued.eventId), ...(state.firstOpen.installAttemptId ? [state.firstOpen.installAttemptId] : []), ...(state.terminalDelivery ? [state.terminalDelivery.eventId] : [])]);
|
|
430
|
+
const delivery = state.terminalDelivery ?? {
|
|
431
|
+
...outcome,
|
|
432
|
+
eventId: makeOutcomeEventId(options.randomUUID, existingEventIds),
|
|
433
|
+
retryable: false,
|
|
434
|
+
occurredAt: new Date(now()).toISOString(),
|
|
435
|
+
attempts: 0,
|
|
436
|
+
nextRetryAt: 0
|
|
437
|
+
};
|
|
438
|
+
state = {
|
|
439
|
+
...state,
|
|
440
|
+
status,
|
|
441
|
+
terminalDelivery: delivery,
|
|
442
|
+
firstOpen: {
|
|
443
|
+
...state.firstOpen,
|
|
444
|
+
completed: true,
|
|
445
|
+
attempts: 0,
|
|
446
|
+
nextRetryAt: 0
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
if (!persistAndNotify()) {
|
|
450
|
+
reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
|
|
451
|
+
}
|
|
452
|
+
// Delivery is deliberately detached from routing/matcher completion.
|
|
453
|
+
void flushTerminalDelivery();
|
|
454
|
+
};
|
|
455
|
+
const blockFirstOpenForDeterministic = () => {
|
|
456
|
+
deterministicEpoch += 1;
|
|
457
|
+
if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return;
|
|
458
|
+
state = {
|
|
459
|
+
...state,
|
|
460
|
+
firstOpen: {
|
|
461
|
+
...state.firstOpen,
|
|
462
|
+
completed: true,
|
|
463
|
+
nextRetryAt: 0
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
persistAndNotify();
|
|
467
|
+
};
|
|
214
468
|
const isAccountReady = () => {
|
|
215
469
|
try {
|
|
216
470
|
return options.isAccountReady?.() ?? false;
|
|
@@ -443,9 +697,24 @@ export function createPulseLinkClient(options) {
|
|
|
443
697
|
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
444
698
|
return false;
|
|
445
699
|
}
|
|
700
|
+
let applied = false;
|
|
701
|
+
let applicationFailed = false;
|
|
702
|
+
let applicationError;
|
|
703
|
+
const applicationEpoch = deterministicEpoch;
|
|
704
|
+
const hasNewerPendingIntent = () => deterministicEpoch !== applicationEpoch && state.pending !== null
|
|
705
|
+
// A provenance upgrade or rejected lower-priority capture for the token already being
|
|
706
|
+
// applied does not represent another destination and must not leave that token pending.
|
|
707
|
+
&& (sourceToken === null || state.pending.token !== sourceToken);
|
|
708
|
+
routingApplicationInFlight = true;
|
|
446
709
|
emitOutcome(link, 'app_open_confirmed');
|
|
447
710
|
emitOutcome(link, 'deferred_link_resolved');
|
|
448
|
-
|
|
711
|
+
// `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
|
|
712
|
+
// a newer accepted destination, it still arrived before the routing callback and must win.
|
|
713
|
+
if (hasNewerPendingIntent()) {
|
|
714
|
+
routingApplicationInFlight = false;
|
|
715
|
+
void process();
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
449
718
|
try {
|
|
450
719
|
if (link.deepLink) {
|
|
451
720
|
applied = await options.onDeepLink(link.deepLink, link);
|
|
@@ -456,18 +725,41 @@ export function createPulseLinkClient(options) {
|
|
|
456
725
|
return false;
|
|
457
726
|
}
|
|
458
727
|
} catch (error) {
|
|
459
|
-
|
|
728
|
+
applicationFailed = true;
|
|
729
|
+
applicationError = error;
|
|
730
|
+
} finally {
|
|
731
|
+
routingApplicationInFlight = false;
|
|
732
|
+
}
|
|
733
|
+
const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
|
|
734
|
+
if (applicationFailed) {
|
|
735
|
+
reportError(applicationError);
|
|
736
|
+
if (newerIntentArrivedDuringApplication) {
|
|
737
|
+
// The current action did not commit. Resume the newer accepted intent that was captured
|
|
738
|
+
// while its callback was suspended, without scheduling a retry for the superseded one.
|
|
739
|
+
void process();
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
460
742
|
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
|
|
461
743
|
return false;
|
|
462
744
|
}
|
|
463
745
|
if (applied === false) {
|
|
746
|
+
if (newerIntentArrivedDuringApplication) {
|
|
747
|
+
void process();
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
464
750
|
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
|
|
465
751
|
return false;
|
|
466
752
|
}
|
|
753
|
+
if (newerIntentArrivedDuringApplication) {
|
|
754
|
+
// Invocation of any host routing callback is the last safe commit boundary. A successful
|
|
755
|
+
// callback may already have navigated, so automatically applying the newly captured token
|
|
756
|
+
// now would create two journeys. Keep it pending and let a fresh SDK lifecycle recover it.
|
|
757
|
+
deterministicTokenDeferredAfterCommittedJourney = state.pending.token;
|
|
758
|
+
}
|
|
467
759
|
const newlyApplied = sourceToken && sourceToken !== link.id ? [link.id, sourceToken] : [link.id];
|
|
468
760
|
const newlyAppliedSet = new Set(newlyApplied);
|
|
469
761
|
const appliedIds = [...state.appliedIds.filter(id => !newlyAppliedSet.has(id)), ...newlyApplied].slice(-MAX_APPLIED_IDS);
|
|
470
|
-
const stillPending = sourceToken !== null
|
|
762
|
+
const stillPending = sourceToken !== null ? state.pending?.token !== sourceToken : newerIntentArrivedDuringApplication;
|
|
471
763
|
state = {
|
|
472
764
|
...state,
|
|
473
765
|
status: stillPending ? 'pending' : 'applied',
|
|
@@ -481,7 +773,9 @@ export function createPulseLinkClient(options) {
|
|
|
481
773
|
accountRetryAt = 0;
|
|
482
774
|
}
|
|
483
775
|
emitOutcome(link, 'action_applied');
|
|
484
|
-
if (stillPending
|
|
776
|
+
if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
|
|
777
|
+
void process();
|
|
778
|
+
}
|
|
485
779
|
return true;
|
|
486
780
|
};
|
|
487
781
|
const resolvePublic = async (token, basis) => {
|
|
@@ -518,8 +812,13 @@ export function createPulseLinkClient(options) {
|
|
|
518
812
|
};
|
|
519
813
|
const processOnce = async () => {
|
|
520
814
|
if (disposed) return;
|
|
815
|
+
// `capture()` can re-enter while any host callback is awaiting. Never start another routing
|
|
816
|
+
// application concurrently, and never auto-drain the exact token preserved after an already
|
|
817
|
+
// committed journey in this client lifecycle.
|
|
818
|
+
if (routingApplicationInFlight) return;
|
|
521
819
|
const pending = state.pending;
|
|
522
820
|
if (pending) {
|
|
821
|
+
if (pending.token === deterministicTokenDeferredAfterCommittedJourney) return;
|
|
523
822
|
if (pending.nextRetryAt > now()) return;
|
|
524
823
|
if (now() - pending.receivedAt > tokenMaxAgeMs) {
|
|
525
824
|
clearPendingAsTerminal();
|
|
@@ -546,7 +845,7 @@ export function createPulseLinkClient(options) {
|
|
|
546
845
|
return;
|
|
547
846
|
}
|
|
548
847
|
try {
|
|
549
|
-
const raw = await options.accountBridge.claim(token);
|
|
848
|
+
const raw = await withPromiseTimeout(() => options.accountBridge.claim(token), requestTimeoutMs, 'account claim');
|
|
550
849
|
if (disposed || state.pending?.token !== token) return;
|
|
551
850
|
const normalizedClaim = normalizeResolved(raw, token, 'account_bound', allowedActions, now());
|
|
552
851
|
const claimed = normalizedClaim ? {
|
|
@@ -555,7 +854,10 @@ export function createPulseLinkClient(options) {
|
|
|
555
854
|
matchGuaranteed: true,
|
|
556
855
|
confidence: 1
|
|
557
856
|
} : null;
|
|
558
|
-
if (claimed)
|
|
857
|
+
if (claimed) {
|
|
858
|
+
blockFirstOpenForDeterministic();
|
|
859
|
+
await applyResolved(claimed, token);
|
|
860
|
+
} else clearPendingAsTerminal();
|
|
559
861
|
} catch (error) {
|
|
560
862
|
reportError(error);
|
|
561
863
|
if (state.pending?.token === token) schedulePendingRetry();
|
|
@@ -563,8 +865,13 @@ export function createPulseLinkClient(options) {
|
|
|
563
865
|
return;
|
|
564
866
|
}
|
|
565
867
|
if (!options.accountBridge || !isAccountReady() || accountRetryAt > now()) return;
|
|
868
|
+
const accountPendingEpoch = deterministicEpoch;
|
|
566
869
|
try {
|
|
567
|
-
const raw = await options.accountBridge.pending();
|
|
870
|
+
const raw = await withPromiseTimeout(() => options.accountBridge.pending(), requestTimeoutMs, 'account pending');
|
|
871
|
+
// A URL/paste/referrer captured while the account lookup was in flight is newer explicit
|
|
872
|
+
// user intent. Ignore the stale bridge response and let the requested drain resolve the
|
|
873
|
+
// captured token; otherwise both deterministic destinations could be applied.
|
|
874
|
+
if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
568
875
|
if (!raw) {
|
|
569
876
|
accountAttempts = 0;
|
|
570
877
|
accountRetryAt = 0;
|
|
@@ -583,14 +890,21 @@ export function createPulseLinkClient(options) {
|
|
|
583
890
|
matchGuaranteed: true,
|
|
584
891
|
confidence: 1
|
|
585
892
|
} : null;
|
|
586
|
-
if (pendingLink
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
893
|
+
if (pendingLink) {
|
|
894
|
+
// Reserve the first-open journey for deterministic recovery before invoking any host
|
|
895
|
+
// callback. This also covers an already-applied account item restored from older SDK
|
|
896
|
+
// state, which must still prevent a second probabilistic destination.
|
|
897
|
+
blockFirstOpenForDeterministic();
|
|
898
|
+
if (!state.appliedIds.includes(pendingLink.id)) {
|
|
899
|
+
await applyResolved(pendingLink, null);
|
|
900
|
+
} else if (state.status !== 'applied') {
|
|
901
|
+
patchState({
|
|
902
|
+
status: 'applied'
|
|
903
|
+
});
|
|
904
|
+
}
|
|
592
905
|
}
|
|
593
906
|
} catch (error) {
|
|
907
|
+
if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
594
908
|
reportError(error);
|
|
595
909
|
// No token was consumed; the authenticated server outbox remains authoritative.
|
|
596
910
|
scheduleAccountRetry();
|
|
@@ -598,6 +912,8 @@ export function createPulseLinkClient(options) {
|
|
|
598
912
|
};
|
|
599
913
|
const process = async () => {
|
|
600
914
|
if (disposed) return;
|
|
915
|
+
// Application delivery is independent: never await it on the routing drain.
|
|
916
|
+
void flushTerminalDelivery();
|
|
601
917
|
processRequested = true;
|
|
602
918
|
if (processing) return processing;
|
|
603
919
|
const run = (async () => {
|
|
@@ -621,6 +937,7 @@ export function createPulseLinkClient(options) {
|
|
|
621
937
|
const token = normalizeDeferredHandoffToken(rawToken);
|
|
622
938
|
if (!token) return false;
|
|
623
939
|
if (state.appliedIds.includes(token)) {
|
|
940
|
+
deterministicEpoch += 1;
|
|
624
941
|
if (!state.firstOpen.completed) patchFirstOpen({
|
|
625
942
|
completed: true,
|
|
626
943
|
nextRetryAt: 0
|
|
@@ -628,7 +945,8 @@ export function createPulseLinkClient(options) {
|
|
|
628
945
|
return true;
|
|
629
946
|
}
|
|
630
947
|
if (state.pending?.token === token) {
|
|
631
|
-
|
|
948
|
+
deterministicEpoch += 1;
|
|
949
|
+
if (CAPTURE_PRIORITY[basis] > CAPTURE_PRIORITY[state.pending.matchBasis]) {
|
|
632
950
|
state = {
|
|
633
951
|
...state,
|
|
634
952
|
pending: {
|
|
@@ -651,6 +969,20 @@ export function createPulseLinkClient(options) {
|
|
|
651
969
|
void process();
|
|
652
970
|
return true;
|
|
653
971
|
}
|
|
972
|
+
|
|
973
|
+
// Exact transports are deterministic, but when more than one arrives during cold start their
|
|
974
|
+
// provenance still has an explicit precedence. A lower-priority late callback must not replace
|
|
975
|
+
// the URL the user intentionally opened; equal priority keeps last-touch behaviour.
|
|
976
|
+
if (state.pending && CAPTURE_PRIORITY[basis] < CAPTURE_PRIORITY[state.pending.matchBasis]) {
|
|
977
|
+
deterministicEpoch += 1;
|
|
978
|
+
if (!state.firstOpen.completed) patchFirstOpen({
|
|
979
|
+
completed: true,
|
|
980
|
+
nextRetryAt: 0
|
|
981
|
+
});
|
|
982
|
+
void process();
|
|
983
|
+
return true;
|
|
984
|
+
}
|
|
985
|
+
deterministicEpoch += 1;
|
|
654
986
|
state = {
|
|
655
987
|
...state,
|
|
656
988
|
status: 'pending',
|
|
@@ -671,14 +1003,27 @@ export function createPulseLinkClient(options) {
|
|
|
671
1003
|
void process();
|
|
672
1004
|
return true;
|
|
673
1005
|
};
|
|
674
|
-
const
|
|
1006
|
+
const matchFirstOpenOnce = async context => {
|
|
675
1007
|
if (disposed) return 'ineligible';
|
|
676
1008
|
lastFirstOpenContext = context;
|
|
677
1009
|
if (state.pending) return 'deterministic_pending';
|
|
678
1010
|
if (state.firstOpen.completed) return 'already_completed';
|
|
679
1011
|
if (state.firstOpen.nextRetryAt > now()) return 'backoff';
|
|
680
1012
|
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
1013
|
+
|
|
1014
|
+
// The client starts account recovery at construction. Join that deterministic rail before
|
|
1015
|
+
// asking the probabilistic matcher; otherwise a slow Billing/account response can navigate
|
|
1016
|
+
// after the probabilistic destination and the user observes both journeys.
|
|
1017
|
+
await process();
|
|
1018
|
+
if (disposed) return 'ineligible';
|
|
1019
|
+
if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1020
|
+
if (options.accountBridge && isAccountReady() && accountRetryAt > now()) {
|
|
1021
|
+
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
1022
|
+
return 'retry_scheduled';
|
|
1023
|
+
}
|
|
1024
|
+
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
681
1025
|
const installAttemptId = state.firstOpen.installAttemptId ?? makeInstallAttemptId(options.randomUUID);
|
|
1026
|
+
const startingDeterministicEpoch = deterministicEpoch;
|
|
682
1027
|
state = {
|
|
683
1028
|
...state,
|
|
684
1029
|
status: 'resolving',
|
|
@@ -706,26 +1051,23 @@ export function createPulseLinkClient(options) {
|
|
|
706
1051
|
},
|
|
707
1052
|
body: JSON.stringify(body)
|
|
708
1053
|
}, requestTimeoutMs);
|
|
709
|
-
if (
|
|
710
|
-
if (state.firstOpen.completed) return 'deterministic_pending';
|
|
1054
|
+
if (disposed) return 'ineligible';
|
|
1055
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
711
1056
|
if (response.status === 204) {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
patchState({
|
|
718
|
-
status: 'idle'
|
|
1057
|
+
completeFirstOpen('idle', {
|
|
1058
|
+
status: 'NOT_FOUND',
|
|
1059
|
+
rail: 'no_route',
|
|
1060
|
+
routed: false,
|
|
1061
|
+
reason: firstOpenNoRouteReason(response)
|
|
719
1062
|
});
|
|
720
1063
|
return 'no_match';
|
|
721
1064
|
}
|
|
722
|
-
if (response.status === 400 || response.status === 404 || response.status === 410) {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
status: 'terminal_error'
|
|
1065
|
+
if (response.status === 400 || response.status === 404 || response.status === 410 || response.status === 413) {
|
|
1066
|
+
completeFirstOpen('terminal_error', {
|
|
1067
|
+
status: 'FAILURE',
|
|
1068
|
+
rail: 'no_route',
|
|
1069
|
+
routed: false,
|
|
1070
|
+
reason: 'invalid_request'
|
|
729
1071
|
});
|
|
730
1072
|
return 'terminal_error';
|
|
731
1073
|
}
|
|
@@ -734,44 +1076,95 @@ export function createPulseLinkClient(options) {
|
|
|
734
1076
|
scheduleFirstOpenRetry();
|
|
735
1077
|
return 'retry_scheduled';
|
|
736
1078
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
1079
|
+
completeFirstOpen('terminal_error', {
|
|
1080
|
+
status: 'FAILURE',
|
|
1081
|
+
rail: 'no_route',
|
|
1082
|
+
routed: false,
|
|
1083
|
+
reason: 'invalid_response'
|
|
740
1084
|
});
|
|
741
|
-
|
|
742
|
-
|
|
1085
|
+
return 'terminal_error';
|
|
1086
|
+
}
|
|
1087
|
+
let rawLink;
|
|
1088
|
+
try {
|
|
1089
|
+
rawLink = await response.json();
|
|
1090
|
+
} catch {
|
|
1091
|
+
completeFirstOpen('terminal_error', {
|
|
1092
|
+
status: 'FAILURE',
|
|
1093
|
+
rail: 'no_route',
|
|
1094
|
+
routed: false,
|
|
1095
|
+
reason: 'invalid_response'
|
|
743
1096
|
});
|
|
744
1097
|
return 'terminal_error';
|
|
745
1098
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
if (!link
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
1099
|
+
if (disposed) return 'ineligible';
|
|
1100
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1101
|
+
const link = normalizeResolved(rawLink, installAttemptId.replace(/-/g, ''), 'unique_probabilistic', allowedActions, now());
|
|
1102
|
+
if (!link) {
|
|
1103
|
+
completeFirstOpen('terminal_error', {
|
|
1104
|
+
status: 'FAILURE',
|
|
1105
|
+
rail: 'no_route',
|
|
1106
|
+
routed: false,
|
|
1107
|
+
reason: 'invalid_response'
|
|
753
1108
|
});
|
|
754
|
-
|
|
755
|
-
|
|
1109
|
+
return 'terminal_error';
|
|
1110
|
+
}
|
|
1111
|
+
// A probabilistic result is navigation intent, never a remote URL transport. Only the two
|
|
1112
|
+
// harmless closed discovery actions are accepted; all billing/account/entitlement actions
|
|
1113
|
+
// and even an app-owned deepLink are rejected before application code runs.
|
|
1114
|
+
if (link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed || link.deepLink !== undefined || !link.action || !PROBABILISTIC_ACTIONS.has(String(link.action)) || sensitiveActions.has(link.action) || !options.onAction) {
|
|
1115
|
+
completeFirstOpen('terminal_error', {
|
|
1116
|
+
status: 'FAILURE',
|
|
1117
|
+
rail: 'no_route',
|
|
1118
|
+
routed: false,
|
|
1119
|
+
reason: 'policy_rejected'
|
|
756
1120
|
});
|
|
757
1121
|
return 'terminal_error';
|
|
758
1122
|
}
|
|
1123
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
759
1124
|
if (await applyResolved(link, null)) {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1125
|
+
// `applyResolved()` returns true only after the host callback crossed its irreversible
|
|
1126
|
+
// commit boundary successfully. A deterministic capture can arrive while that callback
|
|
1127
|
+
// awaits; it remains pending for the next lifecycle, but it must not erase the terminal
|
|
1128
|
+
// truth that this probabilistic journey really routed.
|
|
1129
|
+
completeFirstOpen(state.pending ? 'pending' : 'applied', {
|
|
1130
|
+
status: 'FOUND',
|
|
1131
|
+
rail: 'fast_route',
|
|
1132
|
+
routed: true,
|
|
1133
|
+
reason: 'matched',
|
|
1134
|
+
matchBasis: link.matchBasis,
|
|
1135
|
+
confidence: link.confidence,
|
|
1136
|
+
...(link.campaignId !== undefined ? {
|
|
1137
|
+
campaignId: link.campaignId
|
|
1138
|
+
} : {}),
|
|
1139
|
+
...(link.experimentId !== undefined ? {
|
|
1140
|
+
experimentId: link.experimentId
|
|
1141
|
+
} : {}),
|
|
1142
|
+
...(link.variantId !== undefined ? {
|
|
1143
|
+
variantId: link.variantId
|
|
1144
|
+
} : {})
|
|
764
1145
|
});
|
|
765
1146
|
return 'matched';
|
|
766
1147
|
}
|
|
1148
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
767
1149
|
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
768
1150
|
return 'retry_scheduled';
|
|
769
1151
|
} catch (error) {
|
|
1152
|
+
if (disposed) return 'ineligible';
|
|
770
1153
|
reportError(error);
|
|
1154
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
771
1155
|
scheduleFirstOpenRetry();
|
|
772
1156
|
return 'retry_scheduled';
|
|
773
1157
|
}
|
|
774
1158
|
};
|
|
1159
|
+
const matchFirstOpen = context => {
|
|
1160
|
+
if (firstOpenMatching) return firstOpenMatching;
|
|
1161
|
+
let owned;
|
|
1162
|
+
owned = matchFirstOpenOnce(context).finally(() => {
|
|
1163
|
+
if (firstOpenMatching === owned) firstOpenMatching = null;
|
|
1164
|
+
});
|
|
1165
|
+
firstOpenMatching = owned;
|
|
1166
|
+
return owned;
|
|
1167
|
+
};
|
|
775
1168
|
const captureAndroidInstallReferrer = async bridge => {
|
|
776
1169
|
let raw;
|
|
777
1170
|
try {
|
|
@@ -796,13 +1189,14 @@ export function createPulseLinkClient(options) {
|
|
|
796
1189
|
retryTimer = null;
|
|
797
1190
|
}
|
|
798
1191
|
if (disposed || options.autoRetry === false) return;
|
|
799
|
-
const candidates = [state.pending?.nextRetryAt ?? 0, lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0, accountRetryAt, state.outcomeQueue[0]?.nextRetryAt ?? 0].filter(value => value > now());
|
|
1192
|
+
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());
|
|
800
1193
|
if (candidates.length === 0) return;
|
|
801
1194
|
const next = Math.min(...candidates);
|
|
802
1195
|
retryTimer = setTimeout(() => {
|
|
803
1196
|
retryTimer = null;
|
|
804
1197
|
void process();
|
|
805
1198
|
void flushResolverOutcomes();
|
|
1199
|
+
void flushTerminalDelivery();
|
|
806
1200
|
if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
|
|
807
1201
|
void matchFirstOpen(lastFirstOpenContext);
|
|
808
1202
|
}
|
|
@@ -822,6 +1216,7 @@ export function createPulseLinkClient(options) {
|
|
|
822
1216
|
scheduleWake();
|
|
823
1217
|
void process();
|
|
824
1218
|
void flushResolverOutcomes();
|
|
1219
|
+
void flushTerminalDelivery();
|
|
825
1220
|
return {
|
|
826
1221
|
capture,
|
|
827
1222
|
captureUrl: url => {
|
|
@@ -839,6 +1234,8 @@ export function createPulseLinkClient(options) {
|
|
|
839
1234
|
dispose: () => {
|
|
840
1235
|
if (disposed) return;
|
|
841
1236
|
disposed = true;
|
|
1237
|
+
deterministicEpoch += 1;
|
|
1238
|
+
terminalDeliveryGeneration += 1;
|
|
842
1239
|
if (retryTimer) clearTimeout(retryTimer);
|
|
843
1240
|
retryTimer = null;
|
|
844
1241
|
unsubscribeAccount?.();
|
|
@@ -848,6 +1245,10 @@ export function createPulseLinkClient(options) {
|
|
|
848
1245
|
reset: () => {
|
|
849
1246
|
accountRetryAt = 0;
|
|
850
1247
|
accountAttempts = 0;
|
|
1248
|
+
deterministicEpoch += 1;
|
|
1249
|
+
terminalDeliveryGeneration += 1;
|
|
1250
|
+
terminalDeliverySending = null;
|
|
1251
|
+
deterministicTokenDeferredAfterCommittedJourney = null;
|
|
851
1252
|
state = emptyState();
|
|
852
1253
|
persistAndNotify();
|
|
853
1254
|
}
|
|
@@ -891,11 +1292,11 @@ function stableOutcomeKey(linkId, name) {
|
|
|
891
1292
|
}
|
|
892
1293
|
return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}|${name}`;
|
|
893
1294
|
}
|
|
894
|
-
function boundedMetadata(value) {
|
|
1295
|
+
function boundedMetadata(value, maxLength = 256) {
|
|
895
1296
|
if (value === null) return null;
|
|
896
1297
|
if (typeof value !== 'string' && typeof value !== 'number') return undefined;
|
|
897
1298
|
const result = String(value).trim();
|
|
898
|
-
return result.length > 0 && result.length <=
|
|
1299
|
+
return result.length > 0 && result.length <= maxLength ? result : undefined;
|
|
899
1300
|
}
|
|
900
1301
|
function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now) {
|
|
901
1302
|
if (!raw || typeof raw !== 'object') return null;
|
|
@@ -917,9 +1318,9 @@ function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now)
|
|
|
917
1318
|
if (!Number.isFinite(expiry) || expiry <= now) return null;
|
|
918
1319
|
}
|
|
919
1320
|
const source = boundedMetadata(value.source);
|
|
920
|
-
const campaignId = boundedMetadata(value.campaignId);
|
|
921
|
-
const experimentId = boundedMetadata(value.experimentId);
|
|
922
|
-
const variantId = boundedMetadata(value.variantId);
|
|
1321
|
+
const campaignId = boundedMetadata(value.campaignId, 128);
|
|
1322
|
+
const experimentId = boundedMetadata(value.experimentId, 128);
|
|
1323
|
+
const variantId = boundedMetadata(value.variantId, 128);
|
|
923
1324
|
return {
|
|
924
1325
|
id,
|
|
925
1326
|
...(action ? {
|
|
@@ -956,6 +1357,15 @@ function isEligibleFirstOpen(context, now, maxAgeMs) {
|
|
|
956
1357
|
function normalizeLocale(locale) {
|
|
957
1358
|
return locale.trim().replace(/_/g, '-');
|
|
958
1359
|
}
|
|
1360
|
+
function firstOpenNoRouteReason(response) {
|
|
1361
|
+
let raw = '';
|
|
1362
|
+
try {
|
|
1363
|
+
raw = response.headers?.get('X-Encore-Match-Outcome')?.trim().toLowerCase() ?? '';
|
|
1364
|
+
} catch {
|
|
1365
|
+
raw = '';
|
|
1366
|
+
}
|
|
1367
|
+
return NO_ROUTE_REASONS.has(raw) ? raw : 'unmatched';
|
|
1368
|
+
}
|
|
959
1369
|
function normalizeAnonymousFirstOpenSignals(context) {
|
|
960
1370
|
const appVersion = normalizeSignalString(context.appVersion, VERSION_SIGNAL, 64);
|
|
961
1371
|
const osVersion = normalizeSignalString(context.osVersion, VERSION_SIGNAL, 64);
|
|
@@ -1047,6 +1457,31 @@ async function withTimeout(fetcher, input, init, timeoutMs) {
|
|
|
1047
1457
|
clearTimeout(timer);
|
|
1048
1458
|
}
|
|
1049
1459
|
}
|
|
1460
|
+
async function withPromiseTimeout(factory, timeoutMs, operation) {
|
|
1461
|
+
let timer = null;
|
|
1462
|
+
try {
|
|
1463
|
+
return await Promise.race([Promise.resolve().then(factory), new Promise((_resolve, reject) => {
|
|
1464
|
+
timer = setTimeout(() => reject(new Error(`Pulse Links: ${operation} timed out`)), timeoutMs);
|
|
1465
|
+
})]);
|
|
1466
|
+
} finally {
|
|
1467
|
+
if (timer) clearTimeout(timer);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
async function withTerminalDeliveryTimeout(acknowledgement, timeoutMs) {
|
|
1471
|
+
let timer = null;
|
|
1472
|
+
try {
|
|
1473
|
+
return await Promise.race([acknowledgement, new Promise((_resolve, reject) => {
|
|
1474
|
+
timer = setTimeout(() => reject(new Error('Pulse Links: terminal delivery acknowledgement timed out')), timeoutMs);
|
|
1475
|
+
})]);
|
|
1476
|
+
} finally {
|
|
1477
|
+
if (timer) clearTimeout(timer);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
function normalizeTerminalDeliveryDisposition(value) {
|
|
1481
|
+
if (value === true || value === 'accepted') return 'accepted';
|
|
1482
|
+
if (value === 'drop') return 'drop';
|
|
1483
|
+
return 'retry';
|
|
1484
|
+
}
|
|
1050
1485
|
async function postResolverOutcome(fetcher, resolverBaseUrl, outcome, timeoutMs) {
|
|
1051
1486
|
return withTimeout(fetcher, `${resolverBaseUrl}${encodeURIComponent(outcome.token)}/event/${outcome.name}`, {
|
|
1052
1487
|
method: 'POST',
|
|
@@ -1113,6 +1548,56 @@ function normalizeOccurredAt(value) {
|
|
|
1113
1548
|
if (!Number.isFinite(timestamp)) return null;
|
|
1114
1549
|
return new Date(timestamp).toISOString() === value ? value : null;
|
|
1115
1550
|
}
|
|
1551
|
+
function readTerminalDelivery(raw) {
|
|
1552
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
1553
|
+
const value = raw;
|
|
1554
|
+
const eventId = typeof value.eventId === 'string' && UUID_V4.test(value.eventId) ? value.eventId.toLowerCase() : null;
|
|
1555
|
+
const status = value.status === 'FOUND' || value.status === 'NOT_FOUND' || value.status === 'FAILURE' ? value.status : null;
|
|
1556
|
+
const rail = value.rail === 'fast_route' || value.rail === 'no_route' ? value.rail : null;
|
|
1557
|
+
const reason = typeof value.reason === 'string' ? value.reason : null;
|
|
1558
|
+
const occurredAt = normalizeOccurredAt(value.occurredAt);
|
|
1559
|
+
const attempts = typeof value.attempts === 'number' && Number.isSafeInteger(value.attempts) && value.attempts >= 0 && value.attempts <= 100_000 ? value.attempts : null;
|
|
1560
|
+
const nextRetryAt = typeof value.nextRetryAt === 'number' && Number.isSafeInteger(value.nextRetryAt) && value.nextRetryAt >= 0 && value.nextRetryAt <= 8_640_000_000_000_000 ? value.nextRetryAt : null;
|
|
1561
|
+
if (value.disposition === 'drop') {
|
|
1562
|
+
const allowedKeys = new Set(['eventId', 'disposition', 'attempts', 'nextRetryAt']);
|
|
1563
|
+
if (!eventId || attempts === null || nextRetryAt === null || Object.keys(value).some(key => !allowedKeys.has(key))) return null;
|
|
1564
|
+
return {
|
|
1565
|
+
eventId,
|
|
1566
|
+
disposition: 'drop',
|
|
1567
|
+
attempts,
|
|
1568
|
+
nextRetryAt
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
const matchBasis = typeof value.matchBasis === 'string' && MATCH_BASES.has(value.matchBasis) ? value.matchBasis : null;
|
|
1572
|
+
const confidence = typeof value.confidence === 'number' && Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : null;
|
|
1573
|
+
const metadata = {};
|
|
1574
|
+
let metadataValid = true;
|
|
1575
|
+
for (const key of ['campaignId', 'experimentId', 'variantId']) {
|
|
1576
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
|
|
1577
|
+
const field = value[key];
|
|
1578
|
+
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;
|
|
1579
|
+
}
|
|
1580
|
+
const hasFoundDimensions = Object.prototype.hasOwnProperty.call(value, 'matchBasis') && Object.prototype.hasOwnProperty.call(value, 'confidence');
|
|
1581
|
+
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');
|
|
1582
|
+
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;
|
|
1583
|
+
if (!eventId || !status || !rail || !reason || !occurredAt || attempts === null || nextRetryAt === null || Object.prototype.hasOwnProperty.call(value, 'disposition') || value.retryable !== false || !validSemanticOutcome) return null;
|
|
1584
|
+
return {
|
|
1585
|
+
eventId,
|
|
1586
|
+
status,
|
|
1587
|
+
rail,
|
|
1588
|
+
routed: value.routed,
|
|
1589
|
+
retryable: false,
|
|
1590
|
+
reason,
|
|
1591
|
+
occurredAt,
|
|
1592
|
+
...(status === 'FOUND' ? {
|
|
1593
|
+
matchBasis: matchBasis,
|
|
1594
|
+
confidence: confidence,
|
|
1595
|
+
...metadata
|
|
1596
|
+
} : {}),
|
|
1597
|
+
attempts,
|
|
1598
|
+
nextRetryAt
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1116
1601
|
function readState(storage, key) {
|
|
1117
1602
|
if (!storage) return emptyState();
|
|
1118
1603
|
try {
|
|
@@ -1139,6 +1624,7 @@ function readState(storage, key) {
|
|
|
1139
1624
|
const attemptId = typeof firstValue.installAttemptId === 'string' && INSTALL_ATTEMPT_ID.test(firstValue.installAttemptId) ? firstValue.installAttemptId.toLowerCase() : null;
|
|
1140
1625
|
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) : [];
|
|
1141
1626
|
const outcomeQueue = readResolverOutcomeQueue(parsed.outcomeQueue);
|
|
1627
|
+
const terminalDelivery = readTerminalDelivery(parsed.terminalDelivery);
|
|
1142
1628
|
return {
|
|
1143
1629
|
version: 1,
|
|
1144
1630
|
status: status === 'resolving' ? pending ? 'pending' : 'idle' : status,
|
|
@@ -1147,6 +1633,7 @@ function readState(storage, key) {
|
|
|
1147
1633
|
appliedIds: [...new Set(appliedIds)].slice(-MAX_APPLIED_IDS),
|
|
1148
1634
|
notifiedOutcomes: [...new Set([...notifiedOutcomes, ...outcomeQueue.map(outcome => outcome.transitionKey)])].slice(-MAX_NOTIFIED_OUTCOMES),
|
|
1149
1635
|
outcomeQueue,
|
|
1636
|
+
terminalDelivery,
|
|
1150
1637
|
firstOpen: {
|
|
1151
1638
|
installAttemptId: attemptId,
|
|
1152
1639
|
completed: firstValue.completed === true,
|
|
@@ -1159,11 +1646,24 @@ function readState(storage, key) {
|
|
|
1159
1646
|
return emptyState();
|
|
1160
1647
|
}
|
|
1161
1648
|
}
|
|
1162
|
-
function writeState(storage, key, state) {
|
|
1163
|
-
if (!storage) return;
|
|
1649
|
+
function writeState(storage, key, state, allowKnownAsyncBestEffort = true) {
|
|
1650
|
+
if (!storage) return true;
|
|
1651
|
+
const knownAsync = storage.supportsDurableSyncWrites === false || ASYNCHRONOUS_STORAGE_ADAPTERS.has(storage);
|
|
1652
|
+
if (knownAsync && !allowKnownAsyncBestEffort) return false;
|
|
1164
1653
|
try {
|
|
1165
|
-
|
|
1166
|
-
|
|
1654
|
+
const result = storage.set(key, JSON.stringify(state));
|
|
1655
|
+
if (isThenable(result)) {
|
|
1656
|
+
ASYNCHRONOUS_STORAGE_ADAPTERS.add(storage);
|
|
1657
|
+
void Promise.resolve(result).catch(() => undefined);
|
|
1658
|
+
return false;
|
|
1659
|
+
}
|
|
1660
|
+
return !knownAsync;
|
|
1661
|
+
} catch {
|
|
1662
|
+
return false;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
function isThenable(value) {
|
|
1666
|
+
return typeof value === 'object' && value !== null || typeof value === 'function' ? typeof value.then === 'function' : false;
|
|
1167
1667
|
}
|
|
1168
1668
|
function safeTimestamp(value) {
|
|
1169
1669
|
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|