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/lib/module/links.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
482
|
+
const readAccountReadiness = () => {
|
|
483
|
+
const readinessLifecycleGeneration = lifecycleGeneration;
|
|
484
|
+
const readinessDeterministicEpoch = deterministicEpoch;
|
|
485
|
+
let ready = false;
|
|
469
486
|
try {
|
|
470
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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())
|
|
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
|
|
1021
|
-
|
|
1022
|
-
|
|
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);
|