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.
@@ -184,6 +184,11 @@ export function createPulseLinkClient(options) {
184
184
  let accountRetryAt = 0;
185
185
  let accountAttempts = 0;
186
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;
187
192
  // Host navigation is irreversible once any routing callback has started: it may perform its
188
193
  // side effect synchronously before returning a Promise. Serialize captures against that commit
189
194
  // boundary and keep the newer accepted token durable for the next client lifecycle instead of
@@ -402,6 +407,9 @@ export function createPulseLinkClient(options) {
402
407
  return acknowledgeTerminalDelivery(tombstone);
403
408
  };
404
409
  const completeFirstOpen = (status, outcome) => {
410
+ const completionLifecycleGeneration = lifecycleGeneration;
411
+ const completionDeterministicEpoch = deterministicEpoch;
412
+ const completionIsCurrent = () => !disposed && lifecycleGeneration === completionLifecycleGeneration && deterministicEpoch === completionDeterministicEpoch;
405
413
  let shouldQueue = Boolean(options.onFirstOpenResult);
406
414
  if (shouldQueue && options.shouldQueueFirstOpenResult) {
407
415
  try {
@@ -410,6 +418,9 @@ export function createPulseLinkClient(options) {
410
418
  shouldQueue = false;
411
419
  reportError(error);
412
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;
413
424
  }
414
425
  if (!shouldQueue) {
415
426
  state = {
@@ -453,8 +464,10 @@ export function createPulseLinkClient(options) {
453
464
  void flushTerminalDelivery();
454
465
  };
455
466
  const blockFirstOpenForDeterministic = () => {
467
+ const blockingLifecycleGeneration = lifecycleGeneration;
456
468
  deterministicEpoch += 1;
457
- if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return;
469
+ const blockingDeterministicEpoch = deterministicEpoch;
470
+ if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return true;
458
471
  state = {
459
472
  ...state,
460
473
  firstOpen: {
@@ -464,14 +477,23 @@ export function createPulseLinkClient(options) {
464
477
  }
465
478
  };
466
479
  persistAndNotify();
480
+ return !disposed && lifecycleGeneration === blockingLifecycleGeneration && deterministicEpoch === blockingDeterministicEpoch;
467
481
  };
468
- const isAccountReady = () => {
482
+ const readAccountReadiness = () => {
483
+ const readinessLifecycleGeneration = lifecycleGeneration;
484
+ const readinessDeterministicEpoch = deterministicEpoch;
485
+ let ready = false;
469
486
  try {
470
- return options.isAccountReady?.() ?? false;
487
+ ready = options.isAccountReady?.() ?? false;
471
488
  } catch (error) {
472
489
  reportError(error);
473
- return false;
474
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
+ };
475
497
  };
476
498
  const schedulePendingRetry = () => {
477
499
  const pending = state.pending;
@@ -679,7 +701,9 @@ export function createPulseLinkClient(options) {
679
701
  if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
680
702
  return false;
681
703
  }
682
- if (!isAccountReady()) {
704
+ const accountReadiness = readAccountReadiness();
705
+ if (!accountReadiness.current) return false;
706
+ if (!accountReadiness.ready) {
683
707
  patchState({
684
708
  status: 'waiting_for_account'
685
709
  });
@@ -701,13 +725,23 @@ export function createPulseLinkClient(options) {
701
725
  let applicationFailed = false;
702
726
  let applicationError;
703
727
  const applicationEpoch = deterministicEpoch;
728
+ const applicationLifecycleGeneration = lifecycleGeneration;
729
+ const lifecycleIsCurrent = () => !disposed && lifecycleGeneration === applicationLifecycleGeneration;
704
730
  const hasNewerPendingIntent = () => deterministicEpoch !== applicationEpoch && state.pending !== null
705
731
  // A provenance upgrade or rejected lower-priority capture for the token already being
706
732
  // applied does not represent another destination and must not leave that token pending.
707
733
  && (sourceToken === null || state.pending.token !== sourceToken);
708
734
  routingApplicationInFlight = true;
709
735
  emitOutcome(link, 'app_open_confirmed');
736
+ if (!lifecycleIsCurrent()) {
737
+ routingApplicationInFlight = false;
738
+ return false;
739
+ }
710
740
  emitOutcome(link, 'deferred_link_resolved');
741
+ if (!lifecycleIsCurrent()) {
742
+ routingApplicationInFlight = false;
743
+ return false;
744
+ }
711
745
  // `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
712
746
  // a newer accepted destination, it still arrived before the routing callback and must win.
713
747
  if (hasNewerPendingIntent()) {
@@ -730,10 +764,19 @@ export function createPulseLinkClient(options) {
730
764
  } finally {
731
765
  routingApplicationInFlight = false;
732
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;
733
772
  const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
734
773
  if (applicationFailed) {
735
774
  reportError(applicationError);
736
- if (newerIntentArrivedDuringApplication) {
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()) {
737
780
  // The current action did not commit. Resume the newer accepted intent that was captured
738
781
  // while its callback was suspended, without scheduling a retry for the superseded one.
739
782
  void process();
@@ -768,11 +811,13 @@ export function createPulseLinkClient(options) {
768
811
  appliedIds
769
812
  };
770
813
  persistAndNotify();
814
+ if (!lifecycleIsCurrent()) return false;
771
815
  if (link.matchBasis === 'account_bound') {
772
816
  accountAttempts = 0;
773
817
  accountRetryAt = 0;
774
818
  }
775
819
  emitOutcome(link, 'action_applied');
820
+ if (!lifecycleIsCurrent()) return false;
776
821
  if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
777
822
  void process();
778
823
  }
@@ -825,11 +870,13 @@ export function createPulseLinkClient(options) {
825
870
  return;
826
871
  }
827
872
  const token = pending.token;
873
+ const pendingLifecycleGeneration = lifecycleGeneration;
828
874
  patchState({
829
875
  status: 'resolving'
830
876
  });
877
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
831
878
  const publicResult = await resolvePublic(token, pending.matchBasis);
832
- if (disposed || state.pending?.token !== token) return;
879
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
833
880
  if (publicResult.kind === 'resolved') {
834
881
  await applyResolved(publicResult.link, token);
835
882
  return;
@@ -838,7 +885,15 @@ export function createPulseLinkClient(options) {
838
885
  schedulePendingRetry();
839
886
  return;
840
887
  }
841
- if (!options.accountBridge || !isAccountReady()) {
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) {
842
897
  patchState({
843
898
  status: 'waiting_for_account'
844
899
  });
@@ -846,7 +901,7 @@ export function createPulseLinkClient(options) {
846
901
  }
847
902
  try {
848
903
  const raw = await withPromiseTimeout(() => options.accountBridge.claim(token), requestTimeoutMs, 'account claim');
849
- if (disposed || state.pending?.token !== token) return;
904
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
850
905
  const normalizedClaim = normalizeResolved(raw, token, 'account_bound', allowedActions, now());
851
906
  const claimed = normalizedClaim ? {
852
907
  ...normalizedClaim,
@@ -855,23 +910,28 @@ export function createPulseLinkClient(options) {
855
910
  confidence: 1
856
911
  } : null;
857
912
  if (claimed) {
858
- blockFirstOpenForDeterministic();
913
+ if (!blockFirstOpenForDeterministic()) return;
859
914
  await applyResolved(claimed, token);
860
915
  } else clearPendingAsTerminal();
861
916
  } catch (error) {
917
+ if (disposed || lifecycleGeneration !== pendingLifecycleGeneration || state.pending?.token !== token) return;
862
918
  reportError(error);
863
- if (state.pending?.token === token) schedulePendingRetry();
919
+ if (!disposed && lifecycleGeneration === pendingLifecycleGeneration && state.pending?.token === token) schedulePendingRetry();
864
920
  }
865
921
  return;
866
922
  }
867
- if (!options.accountBridge || !isAccountReady() || accountRetryAt > now()) return;
923
+ if (!options.accountBridge) return;
924
+ const accountReadiness = readAccountReadiness();
925
+ if (!accountReadiness.current || state.pending) return;
926
+ if (!accountReadiness.ready || accountRetryAt > now()) return;
868
927
  const accountPendingEpoch = deterministicEpoch;
928
+ const accountPendingLifecycleGeneration = lifecycleGeneration;
869
929
  try {
870
930
  const raw = await withPromiseTimeout(() => options.accountBridge.pending(), requestTimeoutMs, 'account pending');
871
931
  // A URL/paste/referrer captured while the account lookup was in flight is newer explicit
872
932
  // user intent. Ignore the stale bridge response and let the requested drain resolve the
873
933
  // captured token; otherwise both deterministic destinations could be applied.
874
- if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
934
+ if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
875
935
  if (!raw) {
876
936
  accountAttempts = 0;
877
937
  accountRetryAt = 0;
@@ -894,7 +954,7 @@ export function createPulseLinkClient(options) {
894
954
  // Reserve the first-open journey for deterministic recovery before invoking any host
895
955
  // callback. This also covers an already-applied account item restored from older SDK
896
956
  // state, which must still prevent a second probabilistic destination.
897
- blockFirstOpenForDeterministic();
957
+ if (!blockFirstOpenForDeterministic()) return;
898
958
  if (!state.appliedIds.includes(pendingLink.id)) {
899
959
  await applyResolved(pendingLink, null);
900
960
  } else if (state.status !== 'applied') {
@@ -904,8 +964,9 @@ export function createPulseLinkClient(options) {
904
964
  }
905
965
  }
906
966
  } catch (error) {
907
- if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
967
+ if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
908
968
  reportError(error);
969
+ if (disposed || lifecycleGeneration !== accountPendingLifecycleGeneration || deterministicEpoch !== accountPendingEpoch || state.pending) return;
909
970
  // No token was consumed; the authenticated server outbox remains authoritative.
910
971
  scheduleAccountRetry();
911
972
  }
@@ -1008,7 +1069,14 @@ export function createPulseLinkClient(options) {
1008
1069
  lastFirstOpenContext = context;
1009
1070
  if (state.pending) return 'deterministic_pending';
1010
1071
  if (state.firstOpen.completed) return 'already_completed';
1011
- if (state.firstOpen.nextRetryAt > now()) return 'backoff';
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
+ }
1012
1080
  if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
1013
1081
 
1014
1082
  // The client starts account recovery at construction. Join that deterministic rail before
@@ -1017,13 +1085,20 @@ export function createPulseLinkClient(options) {
1017
1085
  await process();
1018
1086
  if (disposed) return 'ineligible';
1019
1087
  if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
1020
- if (options.accountBridge && isAccountReady() && accountRetryAt > now()) {
1021
- if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
1022
- return 'retry_scheduled';
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
+ }
1023
1097
  }
1024
1098
  if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
1025
1099
  const installAttemptId = state.firstOpen.installAttemptId ?? makeInstallAttemptId(options.randomUUID);
1026
1100
  const startingDeterministicEpoch = deterministicEpoch;
1101
+ const startingLifecycleGeneration = lifecycleGeneration;
1027
1102
  state = {
1028
1103
  ...state,
1029
1104
  status: 'resolving',
@@ -1033,14 +1108,44 @@ export function createPulseLinkClient(options) {
1033
1108
  attemptedAt: now()
1034
1109
  }
1035
1110
  };
1036
- 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);
1037
1136
  const body = {
1038
1137
  appBundleId: context.appBundleId.trim(),
1039
1138
  platform: 'ios',
1040
1139
  locale: normalizeLocale(context.locale),
1041
1140
  firstOpen: true,
1042
1141
  installAttemptId,
1043
- ...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
+ } : {})
1044
1149
  };
1045
1150
  try {
1046
1151
  const response = await withTimeout(fetcher, matchUrlOf(resolverBaseUrl), {
@@ -1166,6 +1271,8 @@ export function createPulseLinkClient(options) {
1166
1271
  return owned;
1167
1272
  };
1168
1273
  const captureAndroidInstallReferrer = async bridge => {
1274
+ const referrerLifecycleGeneration = lifecycleGeneration;
1275
+ const referrerDeterministicEpoch = deterministicEpoch;
1169
1276
  let raw;
1170
1277
  try {
1171
1278
  raw = await bridge.getDeferredHandoff();
@@ -1178,6 +1285,7 @@ export function createPulseLinkClient(options) {
1178
1285
  };
1179
1286
  }
1180
1287
  const result = normalizeAndroidInstallReferrerResult(raw);
1288
+ if (disposed || lifecycleGeneration !== referrerLifecycleGeneration || deterministicEpoch !== referrerDeterministicEpoch) return result;
1181
1289
  if (result.status === 'OK' && result.token) {
1182
1290
  capture(result.token, 'android_install_referrer');
1183
1291
  }
@@ -1234,6 +1342,7 @@ export function createPulseLinkClient(options) {
1234
1342
  dispose: () => {
1235
1343
  if (disposed) return;
1236
1344
  disposed = true;
1345
+ lifecycleGeneration += 1;
1237
1346
  deterministicEpoch += 1;
1238
1347
  terminalDeliveryGeneration += 1;
1239
1348
  if (retryTimer) clearTimeout(retryTimer);
@@ -1245,6 +1354,7 @@ export function createPulseLinkClient(options) {
1245
1354
  reset: () => {
1246
1355
  accountRetryAt = 0;
1247
1356
  accountAttempts = 0;
1357
+ lifecycleGeneration += 1;
1248
1358
  deterministicEpoch += 1;
1249
1359
  terminalDeliveryGeneration += 1;
1250
1360
  terminalDeliverySending = null;
@@ -1445,6 +1555,20 @@ function resolverOutcomeRetryDelay(eventId, attempts, retryBaseMs, retryMaxMs) {
1445
1555
  const jitter = 0.5 + hash / 0xffffffff * 0.5;
1446
1556
  return Math.max(1, Math.floor(ceiling * jitter));
1447
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
+ }
1448
1572
  async function withTimeout(fetcher, input, init, timeoutMs) {
1449
1573
  const controller = new AbortController();
1450
1574
  const timer = setTimeout(() => controller.abort(), timeoutMs);