pulse-updates 1.3.7 → 1.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/android/src/main/java/app/pulse/updates/PulseController.kt +29 -0
- package/ios/PulseUpdates/PulseController.swift +10 -0
- package/lib/commonjs/config.js.map +1 -1
- package/lib/commonjs/init.js +13 -1
- package/lib/commonjs/init.js.map +1 -1
- package/lib/commonjs/links.js +698 -73
- package/lib/commonjs/links.js.map +1 -1
- package/lib/commonjs/track.js +198 -75
- package/lib/commonjs/track.js.map +1 -1
- package/lib/module/config.js.map +1 -1
- package/lib/module/init.js +14 -2
- package/lib/module/init.js.map +1 -1
- package/lib/module/links.js +697 -73
- package/lib/module/links.js.map +1 -1
- package/lib/module/track.js +197 -75
- package/lib/module/track.js.map +1 -1
- package/lib/typescript/config.d.ts +6 -0
- package/lib/typescript/config.d.ts.map +1 -1
- package/lib/typescript/init.d.ts +1 -0
- package/lib/typescript/init.d.ts.map +1 -1
- package/lib/typescript/links.d.ts +63 -0
- package/lib/typescript/links.d.ts.map +1 -1
- package/lib/typescript/track.d.ts +17 -6
- package/lib/typescript/track.d.ts.map +1 -1
- package/package.json +4 -2
- package/scripts/publish.mjs +227 -16
- package/src/config.ts +6 -0
- package/src/init.ts +16 -1
- package/src/links.ts +983 -51
- package/src/track.ts +283 -81
package/lib/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,26 @@ 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
|
+
// Invalidates every host callback that crossed an await when this client is reset/disposed.
|
|
188
|
+
// This is deliberately separate from deterministicEpoch: captures during a committed callback
|
|
189
|
+
// have intentional last-touch semantics, whereas a lifecycle boundary must forbid every stale
|
|
190
|
+
// state write, retry and outcome from the old instance.
|
|
191
|
+
let lifecycleGeneration = 0;
|
|
192
|
+
// Host navigation is irreversible once any routing callback has started: it may perform its
|
|
193
|
+
// side effect synchronously before returning a Promise. Serialize captures against that commit
|
|
194
|
+
// boundary and keep the newer accepted token durable for the next client lifecycle instead of
|
|
195
|
+
// automatically opening a second journey behind the first one.
|
|
196
|
+
let routingApplicationInFlight = false;
|
|
197
|
+
let deterministicTokenDeferredAfterCommittedJourney = null;
|
|
161
198
|
let lastFirstOpenContext = null;
|
|
162
199
|
let unsubscribeAccount = null;
|
|
163
200
|
const listeners = new Set();
|
|
@@ -166,6 +203,25 @@ export function createPulseLinkClient(options) {
|
|
|
166
203
|
options.onError?.(error);
|
|
167
204
|
} catch {/* diagnostic hooks never break link handling */}
|
|
168
205
|
};
|
|
206
|
+
|
|
207
|
+
// A build without a receiver has not opted into retaining this analytics envelope. First replace
|
|
208
|
+
// an old full record with the same minimal sticky tombstone used by an explicit drop. Deletion
|
|
209
|
+
// may fail, but a later app version can then only retry local deletion, never resurrect delivery.
|
|
210
|
+
if (!options.onFirstOpenResult && state.terminalDelivery && state.terminalDelivery.disposition !== 'drop') {
|
|
211
|
+
const tombstone = {
|
|
212
|
+
eventId: state.terminalDelivery.eventId,
|
|
213
|
+
disposition: 'drop',
|
|
214
|
+
attempts: state.terminalDelivery.attempts,
|
|
215
|
+
nextRetryAt: 0
|
|
216
|
+
};
|
|
217
|
+
state = {
|
|
218
|
+
...state,
|
|
219
|
+
terminalDelivery: tombstone
|
|
220
|
+
};
|
|
221
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
222
|
+
reportError(new Error('Pulse Links: stale terminal delivery tombstone was not persisted'));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
169
225
|
const snapshot = () => ({
|
|
170
226
|
status: state.status,
|
|
171
227
|
pending: state.pending ? {
|
|
@@ -177,8 +233,7 @@ export function createPulseLinkClient(options) {
|
|
|
177
233
|
...state.firstOpen
|
|
178
234
|
}
|
|
179
235
|
});
|
|
180
|
-
const
|
|
181
|
-
writeState(options.storage, storageKey, state);
|
|
236
|
+
const notifyState = () => {
|
|
182
237
|
const value = snapshot();
|
|
183
238
|
for (const listener of listeners) {
|
|
184
239
|
try {
|
|
@@ -194,6 +249,11 @@ export function createPulseLinkClient(options) {
|
|
|
194
249
|
}
|
|
195
250
|
scheduleWake();
|
|
196
251
|
};
|
|
252
|
+
const persistAndNotify = () => {
|
|
253
|
+
const persisted = writeState(options.storage, storageKey, state);
|
|
254
|
+
notifyState();
|
|
255
|
+
return persisted;
|
|
256
|
+
};
|
|
197
257
|
const patchState = patch => {
|
|
198
258
|
state = {
|
|
199
259
|
...state,
|
|
@@ -211,13 +271,229 @@ export function createPulseLinkClient(options) {
|
|
|
211
271
|
};
|
|
212
272
|
persistAndNotify();
|
|
213
273
|
};
|
|
214
|
-
const
|
|
274
|
+
const terminalDeliveryOutcome = queued => ({
|
|
275
|
+
eventId: queued.eventId,
|
|
276
|
+
status: queued.status,
|
|
277
|
+
rail: queued.rail,
|
|
278
|
+
routed: queued.routed,
|
|
279
|
+
retryable: false,
|
|
280
|
+
reason: queued.reason,
|
|
281
|
+
occurredAt: queued.occurredAt,
|
|
282
|
+
...(queued.matchBasis !== undefined ? {
|
|
283
|
+
matchBasis: queued.matchBasis
|
|
284
|
+
} : {}),
|
|
285
|
+
...(queued.confidence !== undefined ? {
|
|
286
|
+
confidence: queued.confidence
|
|
287
|
+
} : {}),
|
|
288
|
+
...(queued.campaignId !== undefined ? {
|
|
289
|
+
campaignId: queued.campaignId
|
|
290
|
+
} : {}),
|
|
291
|
+
...(queued.experimentId !== undefined ? {
|
|
292
|
+
experimentId: queued.experimentId
|
|
293
|
+
} : {}),
|
|
294
|
+
...(queued.variantId !== undefined ? {
|
|
295
|
+
variantId: queued.variantId
|
|
296
|
+
} : {})
|
|
297
|
+
});
|
|
298
|
+
const scheduleTerminalDeliveryRetry = queued => {
|
|
299
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return;
|
|
300
|
+
const attempts = Math.min(queued.attempts + 1, 100_000);
|
|
301
|
+
state = {
|
|
302
|
+
...state,
|
|
303
|
+
terminalDelivery: {
|
|
304
|
+
...queued,
|
|
305
|
+
attempts,
|
|
306
|
+
nextRetryAt: now() + resolverOutcomeRetryDelay(queued.eventId, attempts, retryBaseMs, retryMaxMs)
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
persistAndNotify();
|
|
310
|
+
};
|
|
311
|
+
const acknowledgeTerminalDelivery = queued => {
|
|
312
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
313
|
+
const acknowledgedState = {
|
|
314
|
+
...state,
|
|
315
|
+
terminalDelivery: null
|
|
316
|
+
};
|
|
317
|
+
// Clearing the record is itself transactional. If this write fails after the receiver
|
|
318
|
+
// accepted the event, retain and replay the same eventId: that is why the contract is
|
|
319
|
+
// at-least-once and why receivers must deduplicate.
|
|
320
|
+
if (!writeState(options.storage, storageKey, acknowledgedState, false)) {
|
|
321
|
+
reportError(new Error('Pulse Links: terminal delivery acknowledgement was not persisted'));
|
|
322
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
state = acknowledgedState;
|
|
326
|
+
notifyState();
|
|
327
|
+
return true;
|
|
328
|
+
};
|
|
329
|
+
const flushTerminalDelivery = () => {
|
|
330
|
+
if (disposed) return Promise.resolve(false);
|
|
331
|
+
if (terminalDeliverySending) return terminalDeliverySending;
|
|
332
|
+
const queued = state.terminalDelivery;
|
|
333
|
+
if (!queued || queued.nextRetryAt > now()) return Promise.resolve(false);
|
|
334
|
+
if (queued.disposition === 'drop') {
|
|
335
|
+
return Promise.resolve(acknowledgeTerminalDelivery(queued));
|
|
336
|
+
}
|
|
337
|
+
if (!options.onFirstOpenResult) return Promise.resolve(false);
|
|
338
|
+
const generation = terminalDeliveryGeneration;
|
|
339
|
+
const run = async () => {
|
|
340
|
+
// Never call the receiver before the complete record is durable. With no storage adapter,
|
|
341
|
+
// this is an explicitly memory-only degradation rather than a cross-restart guarantee.
|
|
342
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
343
|
+
reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
|
|
344
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
let rawDisposition;
|
|
348
|
+
try {
|
|
349
|
+
rawDisposition = options.onFirstOpenResult(terminalDeliveryOutcome(queued));
|
|
350
|
+
} catch (error) {
|
|
351
|
+
reportError(error);
|
|
352
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
let disposition;
|
|
356
|
+
if (typeof rawDisposition === 'boolean' || typeof rawDisposition === 'string') {
|
|
357
|
+
disposition = normalizeTerminalDeliveryDisposition(rawDisposition);
|
|
358
|
+
} else {
|
|
359
|
+
try {
|
|
360
|
+
const resolved = await withTerminalDeliveryTimeout(Promise.resolve(rawDisposition), terminalDeliveryTimeoutMs);
|
|
361
|
+
disposition = normalizeTerminalDeliveryDisposition(resolved);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (!disposed && terminalDeliveryGeneration === generation) reportError(error);
|
|
364
|
+
if (!disposed && terminalDeliveryGeneration === generation) {
|
|
365
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
366
|
+
}
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (disposed || terminalDeliveryGeneration !== generation || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
371
|
+
if (disposition === 'accepted') return acknowledgeTerminalDelivery(queued);
|
|
372
|
+
if (disposition === 'drop') return dropTerminalDelivery(queued);
|
|
373
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
374
|
+
return false;
|
|
375
|
+
};
|
|
376
|
+
let owned;
|
|
377
|
+
owned = run().finally(() => {
|
|
378
|
+
if (terminalDeliverySending === owned) terminalDeliverySending = null;
|
|
379
|
+
const pending = state.terminalDelivery;
|
|
380
|
+
if (!disposed && options.onFirstOpenResult && pending && pending.nextRetryAt <= now()) {
|
|
381
|
+
void flushTerminalDelivery();
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
terminalDeliverySending = owned;
|
|
385
|
+
return owned;
|
|
386
|
+
};
|
|
387
|
+
const dropTerminalDelivery = queued => {
|
|
388
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
389
|
+
const tombstone = {
|
|
390
|
+
eventId: queued.eventId,
|
|
391
|
+
disposition: 'drop',
|
|
392
|
+
attempts: queued.attempts,
|
|
393
|
+
nextRetryAt: 0
|
|
394
|
+
};
|
|
395
|
+
state = {
|
|
396
|
+
...state,
|
|
397
|
+
terminalDelivery: tombstone
|
|
398
|
+
};
|
|
399
|
+
// Persist the sticky disposition before trying to delete it. A failed local deletion can then
|
|
400
|
+
// retry only deletion after restart; it must never call the receiver or resurrect collection.
|
|
401
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
402
|
+
reportError(new Error('Pulse Links: terminal drop tombstone was not persisted'));
|
|
403
|
+
scheduleTerminalDeliveryRetry(tombstone);
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
notifyState();
|
|
407
|
+
return acknowledgeTerminalDelivery(tombstone);
|
|
408
|
+
};
|
|
409
|
+
const completeFirstOpen = (status, outcome) => {
|
|
410
|
+
const completionLifecycleGeneration = lifecycleGeneration;
|
|
411
|
+
const completionDeterministicEpoch = deterministicEpoch;
|
|
412
|
+
const completionIsCurrent = () => !disposed && lifecycleGeneration === completionLifecycleGeneration && deterministicEpoch === completionDeterministicEpoch;
|
|
413
|
+
let shouldQueue = Boolean(options.onFirstOpenResult);
|
|
414
|
+
if (shouldQueue && options.shouldQueueFirstOpenResult) {
|
|
415
|
+
try {
|
|
416
|
+
shouldQueue = options.shouldQueueFirstOpenResult() === true;
|
|
417
|
+
} catch (error) {
|
|
418
|
+
shouldQueue = false;
|
|
419
|
+
reportError(error);
|
|
420
|
+
}
|
|
421
|
+
// Privacy/diagnostic hooks are host code and may synchronously reset, dispose, or capture a
|
|
422
|
+
// deterministic destination. Never resurrect the terminal first-open state they invalidated.
|
|
423
|
+
if (!completionIsCurrent()) return;
|
|
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
|
+
const blockingLifecycleGeneration = lifecycleGeneration;
|
|
468
|
+
deterministicEpoch += 1;
|
|
469
|
+
const blockingDeterministicEpoch = deterministicEpoch;
|
|
470
|
+
if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return true;
|
|
471
|
+
state = {
|
|
472
|
+
...state,
|
|
473
|
+
firstOpen: {
|
|
474
|
+
...state.firstOpen,
|
|
475
|
+
completed: true,
|
|
476
|
+
nextRetryAt: 0
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
persistAndNotify();
|
|
480
|
+
return !disposed && lifecycleGeneration === blockingLifecycleGeneration && deterministicEpoch === blockingDeterministicEpoch;
|
|
481
|
+
};
|
|
482
|
+
const readAccountReadiness = () => {
|
|
483
|
+
const readinessLifecycleGeneration = lifecycleGeneration;
|
|
484
|
+
const readinessDeterministicEpoch = deterministicEpoch;
|
|
485
|
+
let ready = false;
|
|
215
486
|
try {
|
|
216
|
-
|
|
487
|
+
ready = options.isAccountReady?.() ?? false;
|
|
217
488
|
} catch (error) {
|
|
218
489
|
reportError(error);
|
|
219
|
-
return false;
|
|
220
490
|
}
|
|
491
|
+
return {
|
|
492
|
+
ready,
|
|
493
|
+
// Readiness is host code, not a pure getter. A reset/dispose/capture inside it invalidates
|
|
494
|
+
// the caller's snapshot and must be observed before any waiting/retry/application write.
|
|
495
|
+
current: !disposed && lifecycleGeneration === readinessLifecycleGeneration && deterministicEpoch === readinessDeterministicEpoch
|
|
496
|
+
};
|
|
221
497
|
};
|
|
222
498
|
const schedulePendingRetry = () => {
|
|
223
499
|
const pending = state.pending;
|
|
@@ -425,7 +701,9 @@ export function createPulseLinkClient(options) {
|
|
|
425
701
|
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
426
702
|
return false;
|
|
427
703
|
}
|
|
428
|
-
|
|
704
|
+
const accountReadiness = readAccountReadiness();
|
|
705
|
+
if (!accountReadiness.current) return false;
|
|
706
|
+
if (!accountReadiness.ready) {
|
|
429
707
|
patchState({
|
|
430
708
|
status: 'waiting_for_account'
|
|
431
709
|
});
|
|
@@ -443,9 +721,34 @@ export function createPulseLinkClient(options) {
|
|
|
443
721
|
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
444
722
|
return false;
|
|
445
723
|
}
|
|
724
|
+
let applied = false;
|
|
725
|
+
let applicationFailed = false;
|
|
726
|
+
let applicationError;
|
|
727
|
+
const applicationEpoch = deterministicEpoch;
|
|
728
|
+
const applicationLifecycleGeneration = lifecycleGeneration;
|
|
729
|
+
const lifecycleIsCurrent = () => !disposed && lifecycleGeneration === applicationLifecycleGeneration;
|
|
730
|
+
const hasNewerPendingIntent = () => deterministicEpoch !== applicationEpoch && state.pending !== null
|
|
731
|
+
// A provenance upgrade or rejected lower-priority capture for the token already being
|
|
732
|
+
// applied does not represent another destination and must not leave that token pending.
|
|
733
|
+
&& (sourceToken === null || state.pending.token !== sourceToken);
|
|
734
|
+
routingApplicationInFlight = true;
|
|
446
735
|
emitOutcome(link, 'app_open_confirmed');
|
|
736
|
+
if (!lifecycleIsCurrent()) {
|
|
737
|
+
routingApplicationInFlight = false;
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
447
740
|
emitOutcome(link, 'deferred_link_resolved');
|
|
448
|
-
|
|
741
|
+
if (!lifecycleIsCurrent()) {
|
|
742
|
+
routingApplicationInFlight = false;
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
// `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
|
|
746
|
+
// a newer accepted destination, it still arrived before the routing callback and must win.
|
|
747
|
+
if (hasNewerPendingIntent()) {
|
|
748
|
+
routingApplicationInFlight = false;
|
|
749
|
+
void process();
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
449
752
|
try {
|
|
450
753
|
if (link.deepLink) {
|
|
451
754
|
applied = await options.onDeepLink(link.deepLink, link);
|
|
@@ -456,18 +759,50 @@ export function createPulseLinkClient(options) {
|
|
|
456
759
|
return false;
|
|
457
760
|
}
|
|
458
761
|
} catch (error) {
|
|
459
|
-
|
|
762
|
+
applicationFailed = true;
|
|
763
|
+
applicationError = error;
|
|
764
|
+
} finally {
|
|
765
|
+
routingApplicationInFlight = false;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// The callback may resolve after reset/dispose and after another client has already committed
|
|
769
|
+
// a newer journey into the same storage. The old instance must not schedule a retry, rewrite
|
|
770
|
+
// state, or emit action_applied regardless of whether the callback returned true/false/threw.
|
|
771
|
+
if (!lifecycleIsCurrent()) return false;
|
|
772
|
+
const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
|
|
773
|
+
if (applicationFailed) {
|
|
774
|
+
reportError(applicationError);
|
|
775
|
+
if (!lifecycleIsCurrent()) return false;
|
|
776
|
+
// onError is host code too: it may capture a newer deterministic destination while reporting
|
|
777
|
+
// this failure. Re-evaluate after the callback instead of arming a stale retry from the value
|
|
778
|
+
// observed before diagnostics ran.
|
|
779
|
+
if (hasNewerPendingIntent()) {
|
|
780
|
+
// The current action did not commit. Resume the newer accepted intent that was captured
|
|
781
|
+
// while its callback was suspended, without scheduling a retry for the superseded one.
|
|
782
|
+
void process();
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
460
785
|
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
|
|
461
786
|
return false;
|
|
462
787
|
}
|
|
463
788
|
if (applied === false) {
|
|
789
|
+
if (newerIntentArrivedDuringApplication) {
|
|
790
|
+
void process();
|
|
791
|
+
return false;
|
|
792
|
+
}
|
|
464
793
|
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
|
|
465
794
|
return false;
|
|
466
795
|
}
|
|
796
|
+
if (newerIntentArrivedDuringApplication) {
|
|
797
|
+
// Invocation of any host routing callback is the last safe commit boundary. A successful
|
|
798
|
+
// callback may already have navigated, so automatically applying the newly captured token
|
|
799
|
+
// now would create two journeys. Keep it pending and let a fresh SDK lifecycle recover it.
|
|
800
|
+
deterministicTokenDeferredAfterCommittedJourney = state.pending.token;
|
|
801
|
+
}
|
|
467
802
|
const newlyApplied = sourceToken && sourceToken !== link.id ? [link.id, sourceToken] : [link.id];
|
|
468
803
|
const newlyAppliedSet = new Set(newlyApplied);
|
|
469
804
|
const appliedIds = [...state.appliedIds.filter(id => !newlyAppliedSet.has(id)), ...newlyApplied].slice(-MAX_APPLIED_IDS);
|
|
470
|
-
const stillPending = sourceToken !== null
|
|
805
|
+
const stillPending = sourceToken !== null ? state.pending?.token !== sourceToken : newerIntentArrivedDuringApplication;
|
|
471
806
|
state = {
|
|
472
807
|
...state,
|
|
473
808
|
status: stillPending ? 'pending' : 'applied',
|
|
@@ -476,12 +811,16 @@ export function createPulseLinkClient(options) {
|
|
|
476
811
|
appliedIds
|
|
477
812
|
};
|
|
478
813
|
persistAndNotify();
|
|
814
|
+
if (!lifecycleIsCurrent()) return false;
|
|
479
815
|
if (link.matchBasis === 'account_bound') {
|
|
480
816
|
accountAttempts = 0;
|
|
481
817
|
accountRetryAt = 0;
|
|
482
818
|
}
|
|
483
819
|
emitOutcome(link, 'action_applied');
|
|
484
|
-
if (
|
|
820
|
+
if (!lifecycleIsCurrent()) return false;
|
|
821
|
+
if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
|
|
822
|
+
void process();
|
|
823
|
+
}
|
|
485
824
|
return true;
|
|
486
825
|
};
|
|
487
826
|
const resolvePublic = async (token, basis) => {
|
|
@@ -518,19 +857,26 @@ export function createPulseLinkClient(options) {
|
|
|
518
857
|
};
|
|
519
858
|
const processOnce = async () => {
|
|
520
859
|
if (disposed) return;
|
|
860
|
+
// `capture()` can re-enter while any host callback is awaiting. Never start another routing
|
|
861
|
+
// application concurrently, and never auto-drain the exact token preserved after an already
|
|
862
|
+
// committed journey in this client lifecycle.
|
|
863
|
+
if (routingApplicationInFlight) return;
|
|
521
864
|
const pending = state.pending;
|
|
522
865
|
if (pending) {
|
|
866
|
+
if (pending.token === deterministicTokenDeferredAfterCommittedJourney) return;
|
|
523
867
|
if (pending.nextRetryAt > now()) return;
|
|
524
868
|
if (now() - pending.receivedAt > tokenMaxAgeMs) {
|
|
525
869
|
clearPendingAsTerminal();
|
|
526
870
|
return;
|
|
527
871
|
}
|
|
528
872
|
const token = pending.token;
|
|
873
|
+
const pendingLifecycleGeneration = lifecycleGeneration;
|
|
529
874
|
patchState({
|
|
530
875
|
status: 'resolving'
|
|
531
876
|
});
|
|
877
|
+
if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
532
878
|
const publicResult = await resolvePublic(token, pending.matchBasis);
|
|
533
|
-
if (disposed || state.pending?.token !== token) return;
|
|
879
|
+
if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
534
880
|
if (publicResult.kind === 'resolved') {
|
|
535
881
|
await applyResolved(publicResult.link, token);
|
|
536
882
|
return;
|
|
@@ -539,15 +885,23 @@ export function createPulseLinkClient(options) {
|
|
|
539
885
|
schedulePendingRetry();
|
|
540
886
|
return;
|
|
541
887
|
}
|
|
542
|
-
if (!options.accountBridge
|
|
888
|
+
if (!options.accountBridge) {
|
|
889
|
+
patchState({
|
|
890
|
+
status: 'waiting_for_account'
|
|
891
|
+
});
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
const accountReadiness = readAccountReadiness();
|
|
895
|
+
if (!accountReadiness.current || disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
896
|
+
if (!accountReadiness.ready) {
|
|
543
897
|
patchState({
|
|
544
898
|
status: 'waiting_for_account'
|
|
545
899
|
});
|
|
546
900
|
return;
|
|
547
901
|
}
|
|
548
902
|
try {
|
|
549
|
-
const raw = await options.accountBridge.claim(token);
|
|
550
|
-
if (disposed || state.pending?.token !== token) return;
|
|
903
|
+
const raw = await withPromiseTimeout(() => options.accountBridge.claim(token), requestTimeoutMs, 'account claim');
|
|
904
|
+
if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
551
905
|
const normalizedClaim = normalizeResolved(raw, token, 'account_bound', allowedActions, now());
|
|
552
906
|
const claimed = normalizedClaim ? {
|
|
553
907
|
...normalizedClaim,
|
|
@@ -555,16 +909,29 @@ export function createPulseLinkClient(options) {
|
|
|
555
909
|
matchGuaranteed: true,
|
|
556
910
|
confidence: 1
|
|
557
911
|
} : null;
|
|
558
|
-
if (claimed)
|
|
912
|
+
if (claimed) {
|
|
913
|
+
if (!blockFirstOpenForDeterministic()) return;
|
|
914
|
+
await applyResolved(claimed, token);
|
|
915
|
+
} else clearPendingAsTerminal();
|
|
559
916
|
} catch (error) {
|
|
917
|
+
if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
|
|
560
918
|
reportError(error);
|
|
561
|
-
if (state.pending?.token === token) schedulePendingRetry();
|
|
919
|
+
if (!disposed && lifecycleGeneration === pendingLifecycleGeneration && state.pending?.token === token) schedulePendingRetry();
|
|
562
920
|
}
|
|
563
921
|
return;
|
|
564
922
|
}
|
|
565
|
-
if (!options.accountBridge
|
|
923
|
+
if (!options.accountBridge) return;
|
|
924
|
+
const accountReadiness = readAccountReadiness();
|
|
925
|
+
if (!accountReadiness.current || state.pending) return;
|
|
926
|
+
if (!accountReadiness.ready || accountRetryAt > now()) return;
|
|
927
|
+
const accountPendingEpoch = deterministicEpoch;
|
|
928
|
+
const accountPendingLifecycleGeneration = lifecycleGeneration;
|
|
566
929
|
try {
|
|
567
|
-
const raw = await options.accountBridge.pending();
|
|
930
|
+
const raw = await withPromiseTimeout(() => options.accountBridge.pending(), requestTimeoutMs, 'account pending');
|
|
931
|
+
// A URL/paste/referrer captured while the account lookup was in flight is newer explicit
|
|
932
|
+
// user intent. Ignore the stale bridge response and let the requested drain resolve the
|
|
933
|
+
// captured token; otherwise both deterministic destinations could be applied.
|
|
934
|
+
if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
568
935
|
if (!raw) {
|
|
569
936
|
accountAttempts = 0;
|
|
570
937
|
accountRetryAt = 0;
|
|
@@ -583,21 +950,31 @@ export function createPulseLinkClient(options) {
|
|
|
583
950
|
matchGuaranteed: true,
|
|
584
951
|
confidence: 1
|
|
585
952
|
} : null;
|
|
586
|
-
if (pendingLink
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
953
|
+
if (pendingLink) {
|
|
954
|
+
// Reserve the first-open journey for deterministic recovery before invoking any host
|
|
955
|
+
// callback. This also covers an already-applied account item restored from older SDK
|
|
956
|
+
// state, which must still prevent a second probabilistic destination.
|
|
957
|
+
if (!blockFirstOpenForDeterministic()) return;
|
|
958
|
+
if (!state.appliedIds.includes(pendingLink.id)) {
|
|
959
|
+
await applyResolved(pendingLink, null);
|
|
960
|
+
} else if (state.status !== 'applied') {
|
|
961
|
+
patchState({
|
|
962
|
+
status: 'applied'
|
|
963
|
+
});
|
|
964
|
+
}
|
|
592
965
|
}
|
|
593
966
|
} catch (error) {
|
|
967
|
+
if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
594
968
|
reportError(error);
|
|
969
|
+
if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
595
970
|
// No token was consumed; the authenticated server outbox remains authoritative.
|
|
596
971
|
scheduleAccountRetry();
|
|
597
972
|
}
|
|
598
973
|
};
|
|
599
974
|
const process = async () => {
|
|
600
975
|
if (disposed) return;
|
|
976
|
+
// Application delivery is independent: never await it on the routing drain.
|
|
977
|
+
void flushTerminalDelivery();
|
|
601
978
|
processRequested = true;
|
|
602
979
|
if (processing) return processing;
|
|
603
980
|
const run = (async () => {
|
|
@@ -621,6 +998,7 @@ export function createPulseLinkClient(options) {
|
|
|
621
998
|
const token = normalizeDeferredHandoffToken(rawToken);
|
|
622
999
|
if (!token) return false;
|
|
623
1000
|
if (state.appliedIds.includes(token)) {
|
|
1001
|
+
deterministicEpoch += 1;
|
|
624
1002
|
if (!state.firstOpen.completed) patchFirstOpen({
|
|
625
1003
|
completed: true,
|
|
626
1004
|
nextRetryAt: 0
|
|
@@ -628,7 +1006,8 @@ export function createPulseLinkClient(options) {
|
|
|
628
1006
|
return true;
|
|
629
1007
|
}
|
|
630
1008
|
if (state.pending?.token === token) {
|
|
631
|
-
|
|
1009
|
+
deterministicEpoch += 1;
|
|
1010
|
+
if (CAPTURE_PRIORITY[basis] > CAPTURE_PRIORITY[state.pending.matchBasis]) {
|
|
632
1011
|
state = {
|
|
633
1012
|
...state,
|
|
634
1013
|
pending: {
|
|
@@ -651,6 +1030,20 @@ export function createPulseLinkClient(options) {
|
|
|
651
1030
|
void process();
|
|
652
1031
|
return true;
|
|
653
1032
|
}
|
|
1033
|
+
|
|
1034
|
+
// Exact transports are deterministic, but when more than one arrives during cold start their
|
|
1035
|
+
// provenance still has an explicit precedence. A lower-priority late callback must not replace
|
|
1036
|
+
// the URL the user intentionally opened; equal priority keeps last-touch behaviour.
|
|
1037
|
+
if (state.pending && CAPTURE_PRIORITY[basis] < CAPTURE_PRIORITY[state.pending.matchBasis]) {
|
|
1038
|
+
deterministicEpoch += 1;
|
|
1039
|
+
if (!state.firstOpen.completed) patchFirstOpen({
|
|
1040
|
+
completed: true,
|
|
1041
|
+
nextRetryAt: 0
|
|
1042
|
+
});
|
|
1043
|
+
void process();
|
|
1044
|
+
return true;
|
|
1045
|
+
}
|
|
1046
|
+
deterministicEpoch += 1;
|
|
654
1047
|
state = {
|
|
655
1048
|
...state,
|
|
656
1049
|
status: 'pending',
|
|
@@ -671,14 +1064,41 @@ export function createPulseLinkClient(options) {
|
|
|
671
1064
|
void process();
|
|
672
1065
|
return true;
|
|
673
1066
|
};
|
|
674
|
-
const
|
|
1067
|
+
const matchFirstOpenOnce = async context => {
|
|
675
1068
|
if (disposed) return 'ineligible';
|
|
676
1069
|
lastFirstOpenContext = context;
|
|
677
1070
|
if (state.pending) return 'deterministic_pending';
|
|
678
1071
|
if (state.firstOpen.completed) return 'already_completed';
|
|
679
|
-
if (state.firstOpen.nextRetryAt > now())
|
|
1072
|
+
if (state.firstOpen.nextRetryAt > now()) {
|
|
1073
|
+
// On a fresh client lifecycle scheduleWake() ran before the host supplied this ephemeral
|
|
1074
|
+
// context, so the persisted first-open deadline could not be part of its candidates. Re-arm
|
|
1075
|
+
// now that retrying is possible; otherwise a 503 followed by process death remains asleep
|
|
1076
|
+
// until some unrelated foreground/manual call invokes matchFirstOpen again.
|
|
1077
|
+
scheduleWake();
|
|
1078
|
+
return 'backoff';
|
|
1079
|
+
}
|
|
1080
|
+
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
1081
|
+
|
|
1082
|
+
// The client starts account recovery at construction. Join that deterministic rail before
|
|
1083
|
+
// asking the probabilistic matcher; otherwise a slow Billing/account response can navigate
|
|
1084
|
+
// after the probabilistic destination and the user observes both journeys.
|
|
1085
|
+
await process();
|
|
1086
|
+
if (disposed) return 'ineligible';
|
|
1087
|
+
if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1088
|
+
if (options.accountBridge) {
|
|
1089
|
+
const accountReadiness = readAccountReadiness();
|
|
1090
|
+
if (!accountReadiness.current) {
|
|
1091
|
+
return disposed ? 'ineligible' : 'deterministic_pending';
|
|
1092
|
+
}
|
|
1093
|
+
if (accountReadiness.ready && accountRetryAt > now()) {
|
|
1094
|
+
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
1095
|
+
return 'retry_scheduled';
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
680
1098
|
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
681
1099
|
const installAttemptId = state.firstOpen.installAttemptId ?? makeInstallAttemptId(options.randomUUID);
|
|
1100
|
+
const startingDeterministicEpoch = deterministicEpoch;
|
|
1101
|
+
const startingLifecycleGeneration = lifecycleGeneration;
|
|
682
1102
|
state = {
|
|
683
1103
|
...state,
|
|
684
1104
|
status: 'resolving',
|
|
@@ -688,14 +1108,44 @@ export function createPulseLinkClient(options) {
|
|
|
688
1108
|
attemptedAt: now()
|
|
689
1109
|
}
|
|
690
1110
|
};
|
|
691
|
-
persistAndNotify();
|
|
1111
|
+
const firstOpenReservationPersisted = persistAndNotify();
|
|
1112
|
+
// Persistence notifies host state hooks synchronously. They may reset/dispose this client or
|
|
1113
|
+
// capture a deterministic destination. In all three cases the reserved id no longer belongs to
|
|
1114
|
+
// the current lifecycle and must never cross the network boundary.
|
|
1115
|
+
if (disposed) return 'ineligible';
|
|
1116
|
+
if (lifecycleGeneration !== startingLifecycleGeneration || deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1117
|
+
if (options.storage && !firstOpenReservationPersisted) {
|
|
1118
|
+
// An unpersisted id must never leave the process: after a crash the retry would mint a new
|
|
1119
|
+
// id and the server could count/route the same install twice. Keep the exact nonce in memory,
|
|
1120
|
+
// arm a persistence retry without incrementing matcher attempts, and send nothing until a
|
|
1121
|
+
// synchronous durable write succeeds. Memory-only clients have no restart contract and may
|
|
1122
|
+
// continue normally.
|
|
1123
|
+
state = {
|
|
1124
|
+
...state,
|
|
1125
|
+
status: 'retryable_error',
|
|
1126
|
+
firstOpen: {
|
|
1127
|
+
...state.firstOpen,
|
|
1128
|
+
nextRetryAt: now() + retryBaseMs
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
reportError(new Error('Pulse Links: first-open install attempt id was not durably persisted'));
|
|
1132
|
+
notifyState();
|
|
1133
|
+
return 'retry_scheduled';
|
|
1134
|
+
}
|
|
1135
|
+
const accountEmail = normalizeAccountEmail(context.accountEmail);
|
|
692
1136
|
const body = {
|
|
693
1137
|
appBundleId: context.appBundleId.trim(),
|
|
694
1138
|
platform: 'ios',
|
|
695
1139
|
locale: normalizeLocale(context.locale),
|
|
696
1140
|
firstOpen: true,
|
|
697
1141
|
installAttemptId,
|
|
698
|
-
...normalizeAnonymousFirstOpenSignals(context)
|
|
1142
|
+
...normalizeAnonymousFirstOpenSignals(context),
|
|
1143
|
+
// Sent only when the app already knows it. The resolver treats an address that matches
|
|
1144
|
+
// exactly one recent message as proof of origin; anything else falls back to the
|
|
1145
|
+
// probabilistic path, so a shared or unknown address costs nothing.
|
|
1146
|
+
...(accountEmail ? {
|
|
1147
|
+
accountEmail
|
|
1148
|
+
} : {})
|
|
699
1149
|
};
|
|
700
1150
|
try {
|
|
701
1151
|
const response = await withTimeout(fetcher, matchUrlOf(resolverBaseUrl), {
|
|
@@ -706,26 +1156,23 @@ export function createPulseLinkClient(options) {
|
|
|
706
1156
|
},
|
|
707
1157
|
body: JSON.stringify(body)
|
|
708
1158
|
}, requestTimeoutMs);
|
|
709
|
-
if (
|
|
710
|
-
if (state.firstOpen.completed) return 'deterministic_pending';
|
|
1159
|
+
if (disposed) return 'ineligible';
|
|
1160
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
711
1161
|
if (response.status === 204) {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
patchState({
|
|
718
|
-
status: 'idle'
|
|
1162
|
+
completeFirstOpen('idle', {
|
|
1163
|
+
status: 'NOT_FOUND',
|
|
1164
|
+
rail: 'no_route',
|
|
1165
|
+
routed: false,
|
|
1166
|
+
reason: firstOpenNoRouteReason(response)
|
|
719
1167
|
});
|
|
720
1168
|
return 'no_match';
|
|
721
1169
|
}
|
|
722
|
-
if (response.status === 400 || response.status === 404 || response.status === 410) {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
status: 'terminal_error'
|
|
1170
|
+
if (response.status === 400 || response.status === 404 || response.status === 410 || response.status === 413) {
|
|
1171
|
+
completeFirstOpen('terminal_error', {
|
|
1172
|
+
status: 'FAILURE',
|
|
1173
|
+
rail: 'no_route',
|
|
1174
|
+
routed: false,
|
|
1175
|
+
reason: 'invalid_request'
|
|
729
1176
|
});
|
|
730
1177
|
return 'terminal_error';
|
|
731
1178
|
}
|
|
@@ -734,45 +1181,98 @@ export function createPulseLinkClient(options) {
|
|
|
734
1181
|
scheduleFirstOpenRetry();
|
|
735
1182
|
return 'retry_scheduled';
|
|
736
1183
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
1184
|
+
completeFirstOpen('terminal_error', {
|
|
1185
|
+
status: 'FAILURE',
|
|
1186
|
+
rail: 'no_route',
|
|
1187
|
+
routed: false,
|
|
1188
|
+
reason: 'invalid_response'
|
|
740
1189
|
});
|
|
741
|
-
|
|
742
|
-
|
|
1190
|
+
return 'terminal_error';
|
|
1191
|
+
}
|
|
1192
|
+
let rawLink;
|
|
1193
|
+
try {
|
|
1194
|
+
rawLink = await response.json();
|
|
1195
|
+
} catch {
|
|
1196
|
+
completeFirstOpen('terminal_error', {
|
|
1197
|
+
status: 'FAILURE',
|
|
1198
|
+
rail: 'no_route',
|
|
1199
|
+
routed: false,
|
|
1200
|
+
reason: 'invalid_response'
|
|
743
1201
|
});
|
|
744
1202
|
return 'terminal_error';
|
|
745
1203
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
if (!link
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
1204
|
+
if (disposed) return 'ineligible';
|
|
1205
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1206
|
+
const link = normalizeResolved(rawLink, installAttemptId.replace(/-/g, ''), 'unique_probabilistic', allowedActions, now());
|
|
1207
|
+
if (!link) {
|
|
1208
|
+
completeFirstOpen('terminal_error', {
|
|
1209
|
+
status: 'FAILURE',
|
|
1210
|
+
rail: 'no_route',
|
|
1211
|
+
routed: false,
|
|
1212
|
+
reason: 'invalid_response'
|
|
753
1213
|
});
|
|
754
|
-
|
|
755
|
-
|
|
1214
|
+
return 'terminal_error';
|
|
1215
|
+
}
|
|
1216
|
+
// A probabilistic result is navigation intent, never a remote URL transport. Only the two
|
|
1217
|
+
// harmless closed discovery actions are accepted; all billing/account/entitlement actions
|
|
1218
|
+
// and even an app-owned deepLink are rejected before application code runs.
|
|
1219
|
+
if (link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed || link.deepLink !== undefined || !link.action || !PROBABILISTIC_ACTIONS.has(String(link.action)) || sensitiveActions.has(link.action) || !options.onAction) {
|
|
1220
|
+
completeFirstOpen('terminal_error', {
|
|
1221
|
+
status: 'FAILURE',
|
|
1222
|
+
rail: 'no_route',
|
|
1223
|
+
routed: false,
|
|
1224
|
+
reason: 'policy_rejected'
|
|
756
1225
|
});
|
|
757
1226
|
return 'terminal_error';
|
|
758
1227
|
}
|
|
1228
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
759
1229
|
if (await applyResolved(link, null)) {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1230
|
+
// `applyResolved()` returns true only after the host callback crossed its irreversible
|
|
1231
|
+
// commit boundary successfully. A deterministic capture can arrive while that callback
|
|
1232
|
+
// awaits; it remains pending for the next lifecycle, but it must not erase the terminal
|
|
1233
|
+
// truth that this probabilistic journey really routed.
|
|
1234
|
+
completeFirstOpen(state.pending ? 'pending' : 'applied', {
|
|
1235
|
+
status: 'FOUND',
|
|
1236
|
+
rail: 'fast_route',
|
|
1237
|
+
routed: true,
|
|
1238
|
+
reason: 'matched',
|
|
1239
|
+
matchBasis: link.matchBasis,
|
|
1240
|
+
confidence: link.confidence,
|
|
1241
|
+
...(link.campaignId !== undefined ? {
|
|
1242
|
+
campaignId: link.campaignId
|
|
1243
|
+
} : {}),
|
|
1244
|
+
...(link.experimentId !== undefined ? {
|
|
1245
|
+
experimentId: link.experimentId
|
|
1246
|
+
} : {}),
|
|
1247
|
+
...(link.variantId !== undefined ? {
|
|
1248
|
+
variantId: link.variantId
|
|
1249
|
+
} : {})
|
|
764
1250
|
});
|
|
765
1251
|
return 'matched';
|
|
766
1252
|
}
|
|
1253
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
767
1254
|
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
768
1255
|
return 'retry_scheduled';
|
|
769
1256
|
} catch (error) {
|
|
1257
|
+
if (disposed) return 'ineligible';
|
|
770
1258
|
reportError(error);
|
|
1259
|
+
if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
771
1260
|
scheduleFirstOpenRetry();
|
|
772
1261
|
return 'retry_scheduled';
|
|
773
1262
|
}
|
|
774
1263
|
};
|
|
1264
|
+
const matchFirstOpen = context => {
|
|
1265
|
+
if (firstOpenMatching) return firstOpenMatching;
|
|
1266
|
+
let owned;
|
|
1267
|
+
owned = matchFirstOpenOnce(context).finally(() => {
|
|
1268
|
+
if (firstOpenMatching === owned) firstOpenMatching = null;
|
|
1269
|
+
});
|
|
1270
|
+
firstOpenMatching = owned;
|
|
1271
|
+
return owned;
|
|
1272
|
+
};
|
|
775
1273
|
const captureAndroidInstallReferrer = async bridge => {
|
|
1274
|
+
const referrerLifecycleGeneration = lifecycleGeneration;
|
|
1275
|
+
const referrerDeterministicEpoch = deterministicEpoch;
|
|
776
1276
|
let raw;
|
|
777
1277
|
try {
|
|
778
1278
|
raw = await bridge.getDeferredHandoff();
|
|
@@ -785,6 +1285,7 @@ export function createPulseLinkClient(options) {
|
|
|
785
1285
|
};
|
|
786
1286
|
}
|
|
787
1287
|
const result = normalizeAndroidInstallReferrerResult(raw);
|
|
1288
|
+
if (disposed || lifecycleGeneration !== referrerLifecycleGeneration || deterministicEpoch !== referrerDeterministicEpoch) return result;
|
|
788
1289
|
if (result.status === 'OK' && result.token) {
|
|
789
1290
|
capture(result.token, 'android_install_referrer');
|
|
790
1291
|
}
|
|
@@ -796,13 +1297,14 @@ export function createPulseLinkClient(options) {
|
|
|
796
1297
|
retryTimer = null;
|
|
797
1298
|
}
|
|
798
1299
|
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());
|
|
1300
|
+
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
1301
|
if (candidates.length === 0) return;
|
|
801
1302
|
const next = Math.min(...candidates);
|
|
802
1303
|
retryTimer = setTimeout(() => {
|
|
803
1304
|
retryTimer = null;
|
|
804
1305
|
void process();
|
|
805
1306
|
void flushResolverOutcomes();
|
|
1307
|
+
void flushTerminalDelivery();
|
|
806
1308
|
if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
|
|
807
1309
|
void matchFirstOpen(lastFirstOpenContext);
|
|
808
1310
|
}
|
|
@@ -822,6 +1324,7 @@ export function createPulseLinkClient(options) {
|
|
|
822
1324
|
scheduleWake();
|
|
823
1325
|
void process();
|
|
824
1326
|
void flushResolverOutcomes();
|
|
1327
|
+
void flushTerminalDelivery();
|
|
825
1328
|
return {
|
|
826
1329
|
capture,
|
|
827
1330
|
captureUrl: url => {
|
|
@@ -839,6 +1342,9 @@ export function createPulseLinkClient(options) {
|
|
|
839
1342
|
dispose: () => {
|
|
840
1343
|
if (disposed) return;
|
|
841
1344
|
disposed = true;
|
|
1345
|
+
lifecycleGeneration += 1;
|
|
1346
|
+
deterministicEpoch += 1;
|
|
1347
|
+
terminalDeliveryGeneration += 1;
|
|
842
1348
|
if (retryTimer) clearTimeout(retryTimer);
|
|
843
1349
|
retryTimer = null;
|
|
844
1350
|
unsubscribeAccount?.();
|
|
@@ -848,6 +1354,11 @@ export function createPulseLinkClient(options) {
|
|
|
848
1354
|
reset: () => {
|
|
849
1355
|
accountRetryAt = 0;
|
|
850
1356
|
accountAttempts = 0;
|
|
1357
|
+
lifecycleGeneration += 1;
|
|
1358
|
+
deterministicEpoch += 1;
|
|
1359
|
+
terminalDeliveryGeneration += 1;
|
|
1360
|
+
terminalDeliverySending = null;
|
|
1361
|
+
deterministicTokenDeferredAfterCommittedJourney = null;
|
|
851
1362
|
state = emptyState();
|
|
852
1363
|
persistAndNotify();
|
|
853
1364
|
}
|
|
@@ -891,11 +1402,11 @@ function stableOutcomeKey(linkId, name) {
|
|
|
891
1402
|
}
|
|
892
1403
|
return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}|${name}`;
|
|
893
1404
|
}
|
|
894
|
-
function boundedMetadata(value) {
|
|
1405
|
+
function boundedMetadata(value, maxLength = 256) {
|
|
895
1406
|
if (value === null) return null;
|
|
896
1407
|
if (typeof value !== 'string' && typeof value !== 'number') return undefined;
|
|
897
1408
|
const result = String(value).trim();
|
|
898
|
-
return result.length > 0 && result.length <=
|
|
1409
|
+
return result.length > 0 && result.length <= maxLength ? result : undefined;
|
|
899
1410
|
}
|
|
900
1411
|
function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now) {
|
|
901
1412
|
if (!raw || typeof raw !== 'object') return null;
|
|
@@ -917,9 +1428,9 @@ function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now)
|
|
|
917
1428
|
if (!Number.isFinite(expiry) || expiry <= now) return null;
|
|
918
1429
|
}
|
|
919
1430
|
const source = boundedMetadata(value.source);
|
|
920
|
-
const campaignId = boundedMetadata(value.campaignId);
|
|
921
|
-
const experimentId = boundedMetadata(value.experimentId);
|
|
922
|
-
const variantId = boundedMetadata(value.variantId);
|
|
1431
|
+
const campaignId = boundedMetadata(value.campaignId, 128);
|
|
1432
|
+
const experimentId = boundedMetadata(value.experimentId, 128);
|
|
1433
|
+
const variantId = boundedMetadata(value.variantId, 128);
|
|
923
1434
|
return {
|
|
924
1435
|
id,
|
|
925
1436
|
...(action ? {
|
|
@@ -956,6 +1467,15 @@ function isEligibleFirstOpen(context, now, maxAgeMs) {
|
|
|
956
1467
|
function normalizeLocale(locale) {
|
|
957
1468
|
return locale.trim().replace(/_/g, '-');
|
|
958
1469
|
}
|
|
1470
|
+
function firstOpenNoRouteReason(response) {
|
|
1471
|
+
let raw = '';
|
|
1472
|
+
try {
|
|
1473
|
+
raw = response.headers?.get('X-Encore-Match-Outcome')?.trim().toLowerCase() ?? '';
|
|
1474
|
+
} catch {
|
|
1475
|
+
raw = '';
|
|
1476
|
+
}
|
|
1477
|
+
return NO_ROUTE_REASONS.has(raw) ? raw : 'unmatched';
|
|
1478
|
+
}
|
|
959
1479
|
function normalizeAnonymousFirstOpenSignals(context) {
|
|
960
1480
|
const appVersion = normalizeSignalString(context.appVersion, VERSION_SIGNAL, 64);
|
|
961
1481
|
const osVersion = normalizeSignalString(context.osVersion, VERSION_SIGNAL, 64);
|
|
@@ -1035,6 +1555,20 @@ function resolverOutcomeRetryDelay(eventId, attempts, retryBaseMs, retryMaxMs) {
|
|
|
1035
1555
|
const jitter = 0.5 + hash / 0xffffffff * 0.5;
|
|
1036
1556
|
return Math.max(1, Math.floor(ceiling * jitter));
|
|
1037
1557
|
}
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* An address is only useful to the resolver if it is the same shape the send was recorded with.
|
|
1561
|
+
* Anything that is not plausibly an address is dropped rather than sent: a malformed value can
|
|
1562
|
+
* only ever fail to match, and not sending it keeps the payload free of stray user input.
|
|
1563
|
+
*/
|
|
1564
|
+
export function normalizeAccountEmail(raw) {
|
|
1565
|
+
const value = (raw ?? '').trim().toLowerCase();
|
|
1566
|
+
if (value.length < 3 || value.length > 255) return null;
|
|
1567
|
+
const at = value.indexOf('@');
|
|
1568
|
+
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return null;
|
|
1569
|
+
if (/\s/.test(value)) return null;
|
|
1570
|
+
return value;
|
|
1571
|
+
}
|
|
1038
1572
|
async function withTimeout(fetcher, input, init, timeoutMs) {
|
|
1039
1573
|
const controller = new AbortController();
|
|
1040
1574
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -1047,6 +1581,31 @@ async function withTimeout(fetcher, input, init, timeoutMs) {
|
|
|
1047
1581
|
clearTimeout(timer);
|
|
1048
1582
|
}
|
|
1049
1583
|
}
|
|
1584
|
+
async function withPromiseTimeout(factory, timeoutMs, operation) {
|
|
1585
|
+
let timer = null;
|
|
1586
|
+
try {
|
|
1587
|
+
return await Promise.race([Promise.resolve().then(factory), new Promise((_resolve, reject) => {
|
|
1588
|
+
timer = setTimeout(() => reject(new Error(`Pulse Links: ${operation} timed out`)), timeoutMs);
|
|
1589
|
+
})]);
|
|
1590
|
+
} finally {
|
|
1591
|
+
if (timer) clearTimeout(timer);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
async function withTerminalDeliveryTimeout(acknowledgement, timeoutMs) {
|
|
1595
|
+
let timer = null;
|
|
1596
|
+
try {
|
|
1597
|
+
return await Promise.race([acknowledgement, new Promise((_resolve, reject) => {
|
|
1598
|
+
timer = setTimeout(() => reject(new Error('Pulse Links: terminal delivery acknowledgement timed out')), timeoutMs);
|
|
1599
|
+
})]);
|
|
1600
|
+
} finally {
|
|
1601
|
+
if (timer) clearTimeout(timer);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
function normalizeTerminalDeliveryDisposition(value) {
|
|
1605
|
+
if (value === true || value === 'accepted') return 'accepted';
|
|
1606
|
+
if (value === 'drop') return 'drop';
|
|
1607
|
+
return 'retry';
|
|
1608
|
+
}
|
|
1050
1609
|
async function postResolverOutcome(fetcher, resolverBaseUrl, outcome, timeoutMs) {
|
|
1051
1610
|
return withTimeout(fetcher, `${resolverBaseUrl}${encodeURIComponent(outcome.token)}/event/${outcome.name}`, {
|
|
1052
1611
|
method: 'POST',
|
|
@@ -1113,6 +1672,56 @@ function normalizeOccurredAt(value) {
|
|
|
1113
1672
|
if (!Number.isFinite(timestamp)) return null;
|
|
1114
1673
|
return new Date(timestamp).toISOString() === value ? value : null;
|
|
1115
1674
|
}
|
|
1675
|
+
function readTerminalDelivery(raw) {
|
|
1676
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
1677
|
+
const value = raw;
|
|
1678
|
+
const eventId = typeof value.eventId === 'string' && UUID_V4.test(value.eventId) ? value.eventId.toLowerCase() : null;
|
|
1679
|
+
const status = value.status === 'FOUND' || value.status === 'NOT_FOUND' || value.status === 'FAILURE' ? value.status : null;
|
|
1680
|
+
const rail = value.rail === 'fast_route' || value.rail === 'no_route' ? value.rail : null;
|
|
1681
|
+
const reason = typeof value.reason === 'string' ? value.reason : null;
|
|
1682
|
+
const occurredAt = normalizeOccurredAt(value.occurredAt);
|
|
1683
|
+
const attempts = typeof value.attempts === 'number' && Number.isSafeInteger(value.attempts) && value.attempts >= 0 && value.attempts <= 100_000 ? value.attempts : null;
|
|
1684
|
+
const nextRetryAt = typeof value.nextRetryAt === 'number' && Number.isSafeInteger(value.nextRetryAt) && value.nextRetryAt >= 0 && value.nextRetryAt <= 8_640_000_000_000_000 ? value.nextRetryAt : null;
|
|
1685
|
+
if (value.disposition === 'drop') {
|
|
1686
|
+
const allowedKeys = new Set(['eventId', 'disposition', 'attempts', 'nextRetryAt']);
|
|
1687
|
+
if (!eventId || attempts === null || nextRetryAt === null || Object.keys(value).some(key => !allowedKeys.has(key))) return null;
|
|
1688
|
+
return {
|
|
1689
|
+
eventId,
|
|
1690
|
+
disposition: 'drop',
|
|
1691
|
+
attempts,
|
|
1692
|
+
nextRetryAt
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
const matchBasis = typeof value.matchBasis === 'string' && MATCH_BASES.has(value.matchBasis) ? value.matchBasis : null;
|
|
1696
|
+
const confidence = typeof value.confidence === 'number' && Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : null;
|
|
1697
|
+
const metadata = {};
|
|
1698
|
+
let metadataValid = true;
|
|
1699
|
+
for (const key of ['campaignId', 'experimentId', 'variantId']) {
|
|
1700
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
|
|
1701
|
+
const field = value[key];
|
|
1702
|
+
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;
|
|
1703
|
+
}
|
|
1704
|
+
const hasFoundDimensions = Object.prototype.hasOwnProperty.call(value, 'matchBasis') && Object.prototype.hasOwnProperty.call(value, 'confidence');
|
|
1705
|
+
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');
|
|
1706
|
+
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;
|
|
1707
|
+
if (!eventId || !status || !rail || !reason || !occurredAt || attempts === null || nextRetryAt === null || Object.prototype.hasOwnProperty.call(value, 'disposition') || value.retryable !== false || !validSemanticOutcome) return null;
|
|
1708
|
+
return {
|
|
1709
|
+
eventId,
|
|
1710
|
+
status,
|
|
1711
|
+
rail,
|
|
1712
|
+
routed: value.routed,
|
|
1713
|
+
retryable: false,
|
|
1714
|
+
reason,
|
|
1715
|
+
occurredAt,
|
|
1716
|
+
...(status === 'FOUND' ? {
|
|
1717
|
+
matchBasis: matchBasis,
|
|
1718
|
+
confidence: confidence,
|
|
1719
|
+
...metadata
|
|
1720
|
+
} : {}),
|
|
1721
|
+
attempts,
|
|
1722
|
+
nextRetryAt
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1116
1725
|
function readState(storage, key) {
|
|
1117
1726
|
if (!storage) return emptyState();
|
|
1118
1727
|
try {
|
|
@@ -1139,6 +1748,7 @@ function readState(storage, key) {
|
|
|
1139
1748
|
const attemptId = typeof firstValue.installAttemptId === 'string' && INSTALL_ATTEMPT_ID.test(firstValue.installAttemptId) ? firstValue.installAttemptId.toLowerCase() : null;
|
|
1140
1749
|
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
1750
|
const outcomeQueue = readResolverOutcomeQueue(parsed.outcomeQueue);
|
|
1751
|
+
const terminalDelivery = readTerminalDelivery(parsed.terminalDelivery);
|
|
1142
1752
|
return {
|
|
1143
1753
|
version: 1,
|
|
1144
1754
|
status: status === 'resolving' ? pending ? 'pending' : 'idle' : status,
|
|
@@ -1147,6 +1757,7 @@ function readState(storage, key) {
|
|
|
1147
1757
|
appliedIds: [...new Set(appliedIds)].slice(-MAX_APPLIED_IDS),
|
|
1148
1758
|
notifiedOutcomes: [...new Set([...notifiedOutcomes, ...outcomeQueue.map(outcome => outcome.transitionKey)])].slice(-MAX_NOTIFIED_OUTCOMES),
|
|
1149
1759
|
outcomeQueue,
|
|
1760
|
+
terminalDelivery,
|
|
1150
1761
|
firstOpen: {
|
|
1151
1762
|
installAttemptId: attemptId,
|
|
1152
1763
|
completed: firstValue.completed === true,
|
|
@@ -1159,11 +1770,24 @@ function readState(storage, key) {
|
|
|
1159
1770
|
return emptyState();
|
|
1160
1771
|
}
|
|
1161
1772
|
}
|
|
1162
|
-
function writeState(storage, key, state) {
|
|
1163
|
-
if (!storage) return;
|
|
1773
|
+
function writeState(storage, key, state, allowKnownAsyncBestEffort = true) {
|
|
1774
|
+
if (!storage) return true;
|
|
1775
|
+
const knownAsync = storage.supportsDurableSyncWrites === false || ASYNCHRONOUS_STORAGE_ADAPTERS.has(storage);
|
|
1776
|
+
if (knownAsync && !allowKnownAsyncBestEffort) return false;
|
|
1164
1777
|
try {
|
|
1165
|
-
|
|
1166
|
-
|
|
1778
|
+
const result = storage.set(key, JSON.stringify(state));
|
|
1779
|
+
if (isThenable(result)) {
|
|
1780
|
+
ASYNCHRONOUS_STORAGE_ADAPTERS.add(storage);
|
|
1781
|
+
void Promise.resolve(result).catch(() => undefined);
|
|
1782
|
+
return false;
|
|
1783
|
+
}
|
|
1784
|
+
return !knownAsync;
|
|
1785
|
+
} catch {
|
|
1786
|
+
return false;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
function isThenable(value) {
|
|
1790
|
+
return typeof value === 'object' && value !== null || typeof value === 'function' ? typeof value.then === 'function' : false;
|
|
1167
1791
|
}
|
|
1168
1792
|
function safeTimestamp(value) {
|
|
1169
1793
|
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|