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/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 = (): void => {
876
+ const blockFirstOpenForDeterministic = (): boolean => {
877
+ const blockingLifecycleGeneration = lifecycleGeneration;
856
878
  deterministicEpoch += 1;
857
- if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return;
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 isAccountReady = (): boolean => {
866
- try { return options.isAccountReady?.() ?? false; } catch (error) {
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
- if (!isAccountReady()) {
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 (newerIntentArrivedDuringApplication) {
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 || state.pending?.token !== token) return;
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 || !isAccountReady()) {
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 || state.pending?.token !== token) return;
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 (state.pending?.token === token) schedulePendingRetry();
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 || !isAccountReady() || accountRetryAt > now()) return;
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 || deterministicEpoch !== accountPendingEpoch || state.pending) return;
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 || deterministicEpoch !== accountPendingEpoch || state.pending) return;
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()) return 'backoff';
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 && isAccountReady() && accountRetryAt > now()) {
1464
- if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
1465
- return 'retry_scheduled';
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,