pulse-updates 1.3.8 → 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/android/src/main/java/app/pulse/updates/PulseController.kt +29 -0
- package/ios/PulseUpdates/PulseController.swift +10 -0
- package/lib/commonjs/links.js +146 -21
- package/lib/commonjs/links.js.map +1 -1
- package/lib/module/links.js +145 -21
- package/lib/module/links.js.map +1 -1
- package/lib/typescript/links.d.ts +14 -0
- package/lib/typescript/links.d.ts.map +1 -1
- package/package.json +4 -2
- package/scripts/publish.mjs +227 -16
- package/src/links.ts +183 -21
package/src/links.ts
CHANGED
|
@@ -122,6 +122,14 @@ export interface AnonymousFirstOpenContext {
|
|
|
122
122
|
/** JavaScript Date#getTimezoneOffset semantics, bounded to real-world UTC offsets. */
|
|
123
123
|
timezoneOffsetMinutes?: number;
|
|
124
124
|
isEmulator?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* The address the person is signed in with, when the host app has one. An install that arrives
|
|
127
|
+
* with an account does not need to be guessed at: the resolver binds it to the message sent to
|
|
128
|
+
* that address and skips probabilistic matching entirely. Omitted for signed-out first opens,
|
|
129
|
+
* and never read from the clipboard or any other ambient source — the app passes what it
|
|
130
|
+
* already knows, or nothing.
|
|
131
|
+
*/
|
|
132
|
+
accountEmail?: string;
|
|
125
133
|
}
|
|
126
134
|
|
|
127
135
|
export type AnonymousFirstOpenResult =
|
|
@@ -574,6 +582,11 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
574
582
|
let accountRetryAt = 0;
|
|
575
583
|
let accountAttempts = 0;
|
|
576
584
|
let deterministicEpoch = 0;
|
|
585
|
+
// Invalidates every host callback that crossed an await when this client is reset/disposed.
|
|
586
|
+
// This is deliberately separate from deterministicEpoch: captures during a committed callback
|
|
587
|
+
// have intentional last-touch semantics, whereas a lifecycle boundary must forbid every stale
|
|
588
|
+
// state write, retry and outcome from the old instance.
|
|
589
|
+
let lifecycleGeneration = 0;
|
|
577
590
|
// Host navigation is irreversible once any routing callback has started: it may perform its
|
|
578
591
|
// side effect synchronously before returning a Promise. Serialize captures against that commit
|
|
579
592
|
// boundary and keep the newer accepted token durable for the next client lifecycle instead of
|
|
@@ -796,6 +809,11 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
796
809
|
status: DeferredLinkStatus,
|
|
797
810
|
outcome: Omit<AnonymousFirstOpenTerminalOutcome, 'eventId' | 'occurredAt' | 'retryable'>,
|
|
798
811
|
): void => {
|
|
812
|
+
const completionLifecycleGeneration = lifecycleGeneration;
|
|
813
|
+
const completionDeterministicEpoch = deterministicEpoch;
|
|
814
|
+
const completionIsCurrent = (): boolean => !disposed
|
|
815
|
+
&& lifecycleGeneration === completionLifecycleGeneration
|
|
816
|
+
&& deterministicEpoch === completionDeterministicEpoch;
|
|
799
817
|
let shouldQueue = Boolean(options.onFirstOpenResult);
|
|
800
818
|
if (shouldQueue && options.shouldQueueFirstOpenResult) {
|
|
801
819
|
try {
|
|
@@ -804,6 +822,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
804
822
|
shouldQueue = false;
|
|
805
823
|
reportError(error);
|
|
806
824
|
}
|
|
825
|
+
// Privacy/diagnostic hooks are host code and may synchronously reset, dispose, or capture a
|
|
826
|
+
// deterministic destination. Never resurrect the terminal first-open state they invalidated.
|
|
827
|
+
if (!completionIsCurrent()) return;
|
|
807
828
|
}
|
|
808
829
|
if (!shouldQueue) {
|
|
809
830
|
state = {
|
|
@@ -852,21 +873,36 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
852
873
|
void flushTerminalDelivery();
|
|
853
874
|
};
|
|
854
875
|
|
|
855
|
-
const blockFirstOpenForDeterministic = ():
|
|
876
|
+
const blockFirstOpenForDeterministic = (): boolean => {
|
|
877
|
+
const blockingLifecycleGeneration = lifecycleGeneration;
|
|
856
878
|
deterministicEpoch += 1;
|
|
857
|
-
|
|
879
|
+
const blockingDeterministicEpoch = deterministicEpoch;
|
|
880
|
+
if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return true;
|
|
858
881
|
state = {
|
|
859
882
|
...state,
|
|
860
883
|
firstOpen: { ...state.firstOpen, completed: true, nextRetryAt: 0 },
|
|
861
884
|
};
|
|
862
885
|
persistAndNotify();
|
|
886
|
+
return !disposed
|
|
887
|
+
&& lifecycleGeneration === blockingLifecycleGeneration
|
|
888
|
+
&& deterministicEpoch === blockingDeterministicEpoch;
|
|
863
889
|
};
|
|
864
890
|
|
|
865
|
-
const
|
|
866
|
-
|
|
891
|
+
const readAccountReadiness = (): { ready: boolean; current: boolean } => {
|
|
892
|
+
const readinessLifecycleGeneration = lifecycleGeneration;
|
|
893
|
+
const readinessDeterministicEpoch = deterministicEpoch;
|
|
894
|
+
let ready = false;
|
|
895
|
+
try { ready = options.isAccountReady?.() ?? false; } catch (error) {
|
|
867
896
|
reportError(error);
|
|
868
|
-
return false;
|
|
869
897
|
}
|
|
898
|
+
return {
|
|
899
|
+
ready,
|
|
900
|
+
// Readiness is host code, not a pure getter. A reset/dispose/capture inside it invalidates
|
|
901
|
+
// the caller's snapshot and must be observed before any waiting/retry/application write.
|
|
902
|
+
current: !disposed
|
|
903
|
+
&& lifecycleGeneration === readinessLifecycleGeneration
|
|
904
|
+
&& deterministicEpoch === readinessDeterministicEpoch,
|
|
905
|
+
};
|
|
870
906
|
};
|
|
871
907
|
|
|
872
908
|
const schedulePendingRetry = (): void => {
|
|
@@ -1101,7 +1137,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1101
1137
|
if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
|
|
1102
1138
|
return false;
|
|
1103
1139
|
}
|
|
1104
|
-
|
|
1140
|
+
const accountReadiness = readAccountReadiness();
|
|
1141
|
+
if (!accountReadiness.current) return false;
|
|
1142
|
+
if (!accountReadiness.ready) {
|
|
1105
1143
|
patchState({ status: 'waiting_for_account' });
|
|
1106
1144
|
return false;
|
|
1107
1145
|
}
|
|
@@ -1122,6 +1160,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1122
1160
|
let applicationFailed = false;
|
|
1123
1161
|
let applicationError: unknown;
|
|
1124
1162
|
const applicationEpoch = deterministicEpoch;
|
|
1163
|
+
const applicationLifecycleGeneration = lifecycleGeneration;
|
|
1164
|
+
const lifecycleIsCurrent = (): boolean => !disposed
|
|
1165
|
+
&& lifecycleGeneration === applicationLifecycleGeneration;
|
|
1125
1166
|
const hasNewerPendingIntent = (): boolean => deterministicEpoch !== applicationEpoch
|
|
1126
1167
|
&& state.pending !== null
|
|
1127
1168
|
// A provenance upgrade or rejected lower-priority capture for the token already being
|
|
@@ -1129,7 +1170,15 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1129
1170
|
&& (sourceToken === null || state.pending.token !== sourceToken);
|
|
1130
1171
|
routingApplicationInFlight = true;
|
|
1131
1172
|
emitOutcome(link, 'app_open_confirmed');
|
|
1173
|
+
if (!lifecycleIsCurrent()) {
|
|
1174
|
+
routingApplicationInFlight = false;
|
|
1175
|
+
return false;
|
|
1176
|
+
}
|
|
1132
1177
|
emitOutcome(link, 'deferred_link_resolved');
|
|
1178
|
+
if (!lifecycleIsCurrent()) {
|
|
1179
|
+
routingApplicationInFlight = false;
|
|
1180
|
+
return false;
|
|
1181
|
+
}
|
|
1133
1182
|
// `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
|
|
1134
1183
|
// a newer accepted destination, it still arrived before the routing callback and must win.
|
|
1135
1184
|
if (hasNewerPendingIntent()) {
|
|
@@ -1153,10 +1202,19 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1153
1202
|
routingApplicationInFlight = false;
|
|
1154
1203
|
}
|
|
1155
1204
|
|
|
1205
|
+
// The callback may resolve after reset/dispose and after another client has already committed
|
|
1206
|
+
// a newer journey into the same storage. The old instance must not schedule a retry, rewrite
|
|
1207
|
+
// state, or emit action_applied regardless of whether the callback returned true/false/threw.
|
|
1208
|
+
if (!lifecycleIsCurrent()) return false;
|
|
1209
|
+
|
|
1156
1210
|
const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
|
|
1157
1211
|
if (applicationFailed) {
|
|
1158
1212
|
reportError(applicationError);
|
|
1159
|
-
if (
|
|
1213
|
+
if (!lifecycleIsCurrent()) return false;
|
|
1214
|
+
// onError is host code too: it may capture a newer deterministic destination while reporting
|
|
1215
|
+
// this failure. Re-evaluate after the callback instead of arming a stale retry from the value
|
|
1216
|
+
// observed before diagnostics ran.
|
|
1217
|
+
if (hasNewerPendingIntent()) {
|
|
1160
1218
|
// The current action did not commit. Resume the newer accepted intent that was captured
|
|
1161
1219
|
// while its callback was suspended, without scheduling a retry for the superseded one.
|
|
1162
1220
|
void process();
|
|
@@ -1200,11 +1258,13 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1200
1258
|
appliedIds,
|
|
1201
1259
|
};
|
|
1202
1260
|
persistAndNotify();
|
|
1261
|
+
if (!lifecycleIsCurrent()) return false;
|
|
1203
1262
|
if (link.matchBasis === 'account_bound') {
|
|
1204
1263
|
accountAttempts = 0;
|
|
1205
1264
|
accountRetryAt = 0;
|
|
1206
1265
|
}
|
|
1207
1266
|
emitOutcome(link, 'action_applied');
|
|
1267
|
+
if (!lifecycleIsCurrent()) return false;
|
|
1208
1268
|
if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
|
|
1209
1269
|
void process();
|
|
1210
1270
|
}
|
|
@@ -1258,9 +1318,15 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1258
1318
|
return;
|
|
1259
1319
|
}
|
|
1260
1320
|
const token = pending.token;
|
|
1321
|
+
const pendingLifecycleGeneration = lifecycleGeneration;
|
|
1261
1322
|
patchState({ status: 'resolving' });
|
|
1323
|
+
if (disposed
|
|
1324
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1325
|
+
|| state.pending?.token !== token) return;
|
|
1262
1326
|
const publicResult = await resolvePublic(token, pending.matchBasis);
|
|
1263
|
-
if (disposed
|
|
1327
|
+
if (disposed
|
|
1328
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1329
|
+
|| state.pending?.token !== token) return;
|
|
1264
1330
|
if (publicResult.kind === 'resolved') {
|
|
1265
1331
|
await applyResolved(publicResult.link, token);
|
|
1266
1332
|
return;
|
|
@@ -1270,7 +1336,16 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1270
1336
|
return;
|
|
1271
1337
|
}
|
|
1272
1338
|
|
|
1273
|
-
if (!options.accountBridge
|
|
1339
|
+
if (!options.accountBridge) {
|
|
1340
|
+
patchState({ status: 'waiting_for_account' });
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
const accountReadiness = readAccountReadiness();
|
|
1344
|
+
if (!accountReadiness.current
|
|
1345
|
+
|| disposed
|
|
1346
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1347
|
+
|| state.pending?.token !== token) return;
|
|
1348
|
+
if (!accountReadiness.ready) {
|
|
1274
1349
|
patchState({ status: 'waiting_for_account' });
|
|
1275
1350
|
return;
|
|
1276
1351
|
}
|
|
@@ -1280,7 +1355,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1280
1355
|
requestTimeoutMs,
|
|
1281
1356
|
'account claim',
|
|
1282
1357
|
);
|
|
1283
|
-
if (disposed
|
|
1358
|
+
if (disposed
|
|
1359
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1360
|
+
|| state.pending?.token !== token) return;
|
|
1284
1361
|
const normalizedClaim = normalizeResolved<Action>(
|
|
1285
1362
|
raw,
|
|
1286
1363
|
token,
|
|
@@ -1295,19 +1372,28 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1295
1372
|
confidence: 1,
|
|
1296
1373
|
} : null;
|
|
1297
1374
|
if (claimed) {
|
|
1298
|
-
blockFirstOpenForDeterministic();
|
|
1375
|
+
if (!blockFirstOpenForDeterministic()) return;
|
|
1299
1376
|
await applyResolved(claimed, token);
|
|
1300
1377
|
}
|
|
1301
1378
|
else clearPendingAsTerminal();
|
|
1302
1379
|
} catch (error) {
|
|
1380
|
+
if (disposed
|
|
1381
|
+
|| lifecycleGeneration !== pendingLifecycleGeneration
|
|
1382
|
+
|| state.pending?.token !== token) return;
|
|
1303
1383
|
reportError(error);
|
|
1304
|
-
if (
|
|
1384
|
+
if (!disposed
|
|
1385
|
+
&& lifecycleGeneration === pendingLifecycleGeneration
|
|
1386
|
+
&& state.pending?.token === token) schedulePendingRetry();
|
|
1305
1387
|
}
|
|
1306
1388
|
return;
|
|
1307
1389
|
}
|
|
1308
1390
|
|
|
1309
|
-
if (!options.accountBridge
|
|
1391
|
+
if (!options.accountBridge) return;
|
|
1392
|
+
const accountReadiness = readAccountReadiness();
|
|
1393
|
+
if (!accountReadiness.current || state.pending) return;
|
|
1394
|
+
if (!accountReadiness.ready || accountRetryAt > now()) return;
|
|
1310
1395
|
const accountPendingEpoch = deterministicEpoch;
|
|
1396
|
+
const accountPendingLifecycleGeneration = lifecycleGeneration;
|
|
1311
1397
|
try {
|
|
1312
1398
|
const raw = await withPromiseTimeout(
|
|
1313
1399
|
() => options.accountBridge!.pending(),
|
|
@@ -1317,7 +1403,10 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1317
1403
|
// A URL/paste/referrer captured while the account lookup was in flight is newer explicit
|
|
1318
1404
|
// user intent. Ignore the stale bridge response and let the requested drain resolve the
|
|
1319
1405
|
// captured token; otherwise both deterministic destinations could be applied.
|
|
1320
|
-
if (disposed
|
|
1406
|
+
if (disposed
|
|
1407
|
+
|| lifecycleGeneration !== accountPendingLifecycleGeneration
|
|
1408
|
+
|| deterministicEpoch !== accountPendingEpoch
|
|
1409
|
+
|| state.pending) return;
|
|
1321
1410
|
if (!raw) {
|
|
1322
1411
|
accountAttempts = 0;
|
|
1323
1412
|
accountRetryAt = 0;
|
|
@@ -1346,7 +1435,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1346
1435
|
// Reserve the first-open journey for deterministic recovery before invoking any host
|
|
1347
1436
|
// callback. This also covers an already-applied account item restored from older SDK
|
|
1348
1437
|
// state, which must still prevent a second probabilistic destination.
|
|
1349
|
-
blockFirstOpenForDeterministic();
|
|
1438
|
+
if (!blockFirstOpenForDeterministic()) return;
|
|
1350
1439
|
if (!state.appliedIds.includes(pendingLink.id)) {
|
|
1351
1440
|
await applyResolved(pendingLink, null);
|
|
1352
1441
|
} else if (state.status !== 'applied') {
|
|
@@ -1354,8 +1443,15 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1354
1443
|
}
|
|
1355
1444
|
}
|
|
1356
1445
|
} catch (error) {
|
|
1357
|
-
if (disposed
|
|
1446
|
+
if (disposed
|
|
1447
|
+
|| lifecycleGeneration !== accountPendingLifecycleGeneration
|
|
1448
|
+
|| deterministicEpoch !== accountPendingEpoch
|
|
1449
|
+
|| state.pending) return;
|
|
1358
1450
|
reportError(error);
|
|
1451
|
+
if (disposed
|
|
1452
|
+
|| lifecycleGeneration !== accountPendingLifecycleGeneration
|
|
1453
|
+
|| deterministicEpoch !== accountPendingEpoch
|
|
1454
|
+
|| state.pending) return;
|
|
1359
1455
|
// No token was consumed; the authenticated server outbox remains authoritative.
|
|
1360
1456
|
scheduleAccountRetry();
|
|
1361
1457
|
}
|
|
@@ -1451,7 +1547,14 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1451
1547
|
lastFirstOpenContext = context;
|
|
1452
1548
|
if (state.pending) return 'deterministic_pending';
|
|
1453
1549
|
if (state.firstOpen.completed) return 'already_completed';
|
|
1454
|
-
if (state.firstOpen.nextRetryAt > now())
|
|
1550
|
+
if (state.firstOpen.nextRetryAt > now()) {
|
|
1551
|
+
// On a fresh client lifecycle scheduleWake() ran before the host supplied this ephemeral
|
|
1552
|
+
// context, so the persisted first-open deadline could not be part of its candidates. Re-arm
|
|
1553
|
+
// now that retrying is possible; otherwise a 503 followed by process death remains asleep
|
|
1554
|
+
// until some unrelated foreground/manual call invokes matchFirstOpen again.
|
|
1555
|
+
scheduleWake();
|
|
1556
|
+
return 'backoff';
|
|
1557
|
+
}
|
|
1455
1558
|
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
1456
1559
|
|
|
1457
1560
|
// The client starts account recovery at construction. Join that deterministic rail before
|
|
@@ -1460,15 +1563,22 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1460
1563
|
await process();
|
|
1461
1564
|
if (disposed) return 'ineligible';
|
|
1462
1565
|
if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1463
|
-
if (options.accountBridge
|
|
1464
|
-
|
|
1465
|
-
|
|
1566
|
+
if (options.accountBridge) {
|
|
1567
|
+
const accountReadiness = readAccountReadiness();
|
|
1568
|
+
if (!accountReadiness.current) {
|
|
1569
|
+
return disposed ? 'ineligible' : 'deterministic_pending';
|
|
1570
|
+
}
|
|
1571
|
+
if (accountReadiness.ready && accountRetryAt > now()) {
|
|
1572
|
+
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
1573
|
+
return 'retry_scheduled';
|
|
1574
|
+
}
|
|
1466
1575
|
}
|
|
1467
1576
|
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
1468
1577
|
|
|
1469
1578
|
const installAttemptId = state.firstOpen.installAttemptId
|
|
1470
1579
|
?? makeInstallAttemptId(options.randomUUID);
|
|
1471
1580
|
const startingDeterministicEpoch = deterministicEpoch;
|
|
1581
|
+
const startingLifecycleGeneration = lifecycleGeneration;
|
|
1472
1582
|
state = {
|
|
1473
1583
|
...state,
|
|
1474
1584
|
status: 'resolving',
|
|
@@ -1478,8 +1588,35 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1478
1588
|
attemptedAt: now(),
|
|
1479
1589
|
},
|
|
1480
1590
|
};
|
|
1481
|
-
persistAndNotify();
|
|
1591
|
+
const firstOpenReservationPersisted = persistAndNotify();
|
|
1592
|
+
// Persistence notifies host state hooks synchronously. They may reset/dispose this client or
|
|
1593
|
+
// capture a deterministic destination. In all three cases the reserved id no longer belongs to
|
|
1594
|
+
// the current lifecycle and must never cross the network boundary.
|
|
1595
|
+
if (disposed) return 'ineligible';
|
|
1596
|
+
if (lifecycleGeneration !== startingLifecycleGeneration
|
|
1597
|
+
|| deterministicEpoch !== startingDeterministicEpoch
|
|
1598
|
+
|| state.pending
|
|
1599
|
+
|| state.firstOpen.completed) return 'deterministic_pending';
|
|
1600
|
+
if (options.storage && !firstOpenReservationPersisted) {
|
|
1601
|
+
// An unpersisted id must never leave the process: after a crash the retry would mint a new
|
|
1602
|
+
// id and the server could count/route the same install twice. Keep the exact nonce in memory,
|
|
1603
|
+
// arm a persistence retry without incrementing matcher attempts, and send nothing until a
|
|
1604
|
+
// synchronous durable write succeeds. Memory-only clients have no restart contract and may
|
|
1605
|
+
// continue normally.
|
|
1606
|
+
state = {
|
|
1607
|
+
...state,
|
|
1608
|
+
status: 'retryable_error',
|
|
1609
|
+
firstOpen: {
|
|
1610
|
+
...state.firstOpen,
|
|
1611
|
+
nextRetryAt: now() + retryBaseMs,
|
|
1612
|
+
},
|
|
1613
|
+
};
|
|
1614
|
+
reportError(new Error('Pulse Links: first-open install attempt id was not durably persisted'));
|
|
1615
|
+
notifyState();
|
|
1616
|
+
return 'retry_scheduled';
|
|
1617
|
+
}
|
|
1482
1618
|
|
|
1619
|
+
const accountEmail = normalizeAccountEmail(context.accountEmail);
|
|
1483
1620
|
const body = {
|
|
1484
1621
|
appBundleId: context.appBundleId.trim(),
|
|
1485
1622
|
platform: 'ios' as const,
|
|
@@ -1487,6 +1624,10 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1487
1624
|
firstOpen: true,
|
|
1488
1625
|
installAttemptId,
|
|
1489
1626
|
...normalizeAnonymousFirstOpenSignals(context),
|
|
1627
|
+
// Sent only when the app already knows it. The resolver treats an address that matches
|
|
1628
|
+
// exactly one recent message as proof of origin; anything else falls back to the
|
|
1629
|
+
// probabilistic path, so a shared or unknown address costs nothing.
|
|
1630
|
+
...(accountEmail ? { accountEmail } : {}),
|
|
1490
1631
|
};
|
|
1491
1632
|
try {
|
|
1492
1633
|
const response = await withTimeout(
|
|
@@ -1637,6 +1778,8 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1637
1778
|
const captureAndroidInstallReferrer = async (
|
|
1638
1779
|
bridge: AndroidInstallReferrerBridge,
|
|
1639
1780
|
): Promise<AndroidInstallReferrerResult> => {
|
|
1781
|
+
const referrerLifecycleGeneration = lifecycleGeneration;
|
|
1782
|
+
const referrerDeterministicEpoch = deterministicEpoch;
|
|
1640
1783
|
let raw: unknown;
|
|
1641
1784
|
try {
|
|
1642
1785
|
raw = await bridge.getDeferredHandoff();
|
|
@@ -1645,6 +1788,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1645
1788
|
return { status: 'SERVICE_UNAVAILABLE', retryable: true, attempts: 0 };
|
|
1646
1789
|
}
|
|
1647
1790
|
const result = normalizeAndroidInstallReferrerResult(raw);
|
|
1791
|
+
if (disposed
|
|
1792
|
+
|| lifecycleGeneration !== referrerLifecycleGeneration
|
|
1793
|
+
|| deterministicEpoch !== referrerDeterministicEpoch) return result;
|
|
1648
1794
|
if (result.status === 'OK' && result.token) {
|
|
1649
1795
|
capture(result.token, 'android_install_referrer');
|
|
1650
1796
|
}
|
|
@@ -1708,6 +1854,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1708
1854
|
dispose: () => {
|
|
1709
1855
|
if (disposed) return;
|
|
1710
1856
|
disposed = true;
|
|
1857
|
+
lifecycleGeneration += 1;
|
|
1711
1858
|
deterministicEpoch += 1;
|
|
1712
1859
|
terminalDeliveryGeneration += 1;
|
|
1713
1860
|
if (retryTimer) clearTimeout(retryTimer);
|
|
@@ -1719,6 +1866,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1719
1866
|
reset: () => {
|
|
1720
1867
|
accountRetryAt = 0;
|
|
1721
1868
|
accountAttempts = 0;
|
|
1869
|
+
lifecycleGeneration += 1;
|
|
1722
1870
|
deterministicEpoch += 1;
|
|
1723
1871
|
terminalDeliveryGeneration += 1;
|
|
1724
1872
|
terminalDeliverySending = null;
|
|
@@ -1967,6 +2115,20 @@ function resolverOutcomeRetryDelay(
|
|
|
1967
2115
|
return Math.max(1, Math.floor(ceiling * jitter));
|
|
1968
2116
|
}
|
|
1969
2117
|
|
|
2118
|
+
/**
|
|
2119
|
+
* An address is only useful to the resolver if it is the same shape the send was recorded with.
|
|
2120
|
+
* Anything that is not plausibly an address is dropped rather than sent: a malformed value can
|
|
2121
|
+
* only ever fail to match, and not sending it keeps the payload free of stray user input.
|
|
2122
|
+
*/
|
|
2123
|
+
export function normalizeAccountEmail(raw: string | undefined | null): string | null {
|
|
2124
|
+
const value = (raw ?? '').trim().toLowerCase();
|
|
2125
|
+
if (value.length < 3 || value.length > 255) return null;
|
|
2126
|
+
const at = value.indexOf('@');
|
|
2127
|
+
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return null;
|
|
2128
|
+
if (/\s/.test(value)) return null;
|
|
2129
|
+
return value;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
1970
2132
|
async function withTimeout(
|
|
1971
2133
|
fetcher: typeof fetch,
|
|
1972
2134
|
input: string,
|