pulse-updates 1.2.2 → 1.2.4
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 +31 -0
- package/ios/PulseUpdates/PulseController.swift +27 -1
- package/ios/PulseUpdates/PulseUpdates.swift +4 -0
- package/lib/commonjs/config.js +177 -12
- package/lib/commonjs/config.js.map +1 -1
- package/lib/commonjs/decisions.js +85 -8
- package/lib/commonjs/decisions.js.map +1 -1
- package/lib/commonjs/init.js +4 -1
- package/lib/commonjs/init.js.map +1 -1
- package/lib/module/config.js +174 -12
- package/lib/module/config.js.map +1 -1
- package/lib/module/decisions.js +85 -8
- package/lib/module/decisions.js.map +1 -1
- package/lib/module/init.js +5 -2
- package/lib/module/init.js.map +1 -1
- package/lib/typescript/config.d.ts +10 -0
- package/lib/typescript/config.d.ts.map +1 -1
- package/lib/typescript/decisions.d.ts +12 -0
- package/lib/typescript/decisions.d.ts.map +1 -1
- package/lib/typescript/init.d.ts +4 -1
- package/lib/typescript/init.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +205 -11
- package/src/decisions.ts +128 -10
- package/src/init.ts +16 -1
package/src/config.ts
CHANGED
|
@@ -113,6 +113,8 @@ export interface ConfigOptions {
|
|
|
113
113
|
* the mistake that announces itself.
|
|
114
114
|
*/
|
|
115
115
|
reportExposure?: boolean;
|
|
116
|
+
/** Durable exposure outbox key. initPulse supplies an app-scoped value. */
|
|
117
|
+
exposureStorageKey?: string;
|
|
116
118
|
/** Network timeout per request. */
|
|
117
119
|
timeoutMs?: number;
|
|
118
120
|
/** Ed25519 public key, as raw 32-byte base64. Enables built-in verification. */
|
|
@@ -150,9 +152,21 @@ interface ConfigSnapshot {
|
|
|
150
152
|
experiments: ConfigExperiment[];
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
interface QueuedExposure {
|
|
156
|
+
reportId: string;
|
|
157
|
+
signature: string;
|
|
158
|
+
platform?: string;
|
|
159
|
+
appVersion?: string;
|
|
160
|
+
assignments: Record<string, string>;
|
|
161
|
+
attempts: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
153
164
|
const STORAGE_KEY = 'pulse.config.v1';
|
|
154
165
|
const DEFAULT_POLL_MS = 5 * 60 * 1000;
|
|
155
166
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
167
|
+
const DEFAULT_EXPOSURE_RETRY_MS = 10_000;
|
|
168
|
+
const DEFAULT_EXPOSURE_STORAGE_KEY = 'pulse.exposures.v1';
|
|
169
|
+
const MAX_EXPOSURE_QUEUE = 50;
|
|
156
170
|
|
|
157
171
|
let options: ConfigOptions | null = null;
|
|
158
172
|
let defaults: Record<string, ConfigValue> = {};
|
|
@@ -177,6 +191,11 @@ let signatureKeyId: string | null = null;
|
|
|
177
191
|
let pending: ConfigSnapshot | null = null;
|
|
178
192
|
/** The arm set already reported this session, so a poll does not re-report it. */
|
|
179
193
|
let reportedExposure: string | null = null;
|
|
194
|
+
let exposureQueue: QueuedExposure[] = [];
|
|
195
|
+
let deliveredExposureSignatures = new Set<string>();
|
|
196
|
+
let exposureSending = false;
|
|
197
|
+
let exposureNextAttemptAt = 0;
|
|
198
|
+
let exposureTimer: ReturnType<typeof setInterval> | null = null;
|
|
180
199
|
let inFlight: Promise<boolean> | null = null;
|
|
181
200
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
182
201
|
let configGeneration = 0;
|
|
@@ -193,6 +212,7 @@ export function configureConfig(opts: ConfigOptions): void {
|
|
|
193
212
|
// may cross that boundary.
|
|
194
213
|
configGeneration += 1;
|
|
195
214
|
stopPolling();
|
|
215
|
+
stopExposureDelivery();
|
|
196
216
|
options = opts;
|
|
197
217
|
defaults = { ...(opts.defaults ?? {}) };
|
|
198
218
|
values = {};
|
|
@@ -205,6 +225,10 @@ export function configureConfig(opts: ConfigOptions): void {
|
|
|
205
225
|
signatureKeyId = null;
|
|
206
226
|
pending = null;
|
|
207
227
|
reportedExposure = null;
|
|
228
|
+
exposureQueue = readExposureQueue(opts);
|
|
229
|
+
deliveredExposureSignatures = readDeliveredExposureSignatures(opts);
|
|
230
|
+
exposureSending = false;
|
|
231
|
+
exposureNextAttemptAt = 0;
|
|
208
232
|
inFlight = null;
|
|
209
233
|
|
|
210
234
|
applyCachedPayload(readCache(opts.storage, opts.storageKey), opts);
|
|
@@ -322,6 +346,8 @@ export function startConfigAutoRefresh(appState?: {
|
|
|
322
346
|
addEventListener: (type: 'change', handler: (state: string) => void) => { remove: () => void };
|
|
323
347
|
}): () => void {
|
|
324
348
|
void fetchConfig();
|
|
349
|
+
startExposureDelivery();
|
|
350
|
+
void flushExposures();
|
|
325
351
|
|
|
326
352
|
const subscription = appState?.addEventListener('change', (state) => {
|
|
327
353
|
if (state === 'active') void fetchConfig();
|
|
@@ -339,6 +365,7 @@ export function startConfigAutoRefresh(appState?: {
|
|
|
339
365
|
return () => {
|
|
340
366
|
subscription?.remove();
|
|
341
367
|
stopPolling();
|
|
368
|
+
stopExposureDelivery();
|
|
342
369
|
};
|
|
343
370
|
}
|
|
344
371
|
|
|
@@ -360,7 +387,7 @@ function applySnapshot(next: ConfigSnapshot, opts: ConfigOptions): boolean {
|
|
|
360
387
|
// Reported on apply, never on fetch: with activateOnFetch false a payload can sit
|
|
361
388
|
// unapplied for the rest of a session, and an arm the app is not actually serving
|
|
362
389
|
// is not an exposure.
|
|
363
|
-
|
|
390
|
+
queueExposure(opts);
|
|
364
391
|
|
|
365
392
|
// Listeners drive re-renders and gate re-evaluation: firing them for an identical
|
|
366
393
|
// payload is pure churn, and the 200-with-same-content case is common enough
|
|
@@ -386,34 +413,100 @@ function applySnapshot(next: ConfigSnapshot, opts: ConfigOptions): boolean {
|
|
|
386
413
|
* Failure is swallowed on purpose. This is a diagnostic, and no diagnostic is worth
|
|
387
414
|
* a rejected promise on the path that delivers config to the app.
|
|
388
415
|
*/
|
|
389
|
-
|
|
416
|
+
function queueExposure(opts: ConfigOptions): void {
|
|
390
417
|
if (opts.reportExposure !== true) return;
|
|
391
418
|
if (experiments.length === 0) return;
|
|
392
419
|
|
|
393
420
|
const signature = experiments.map((e) => `${e.key}=${e.variant}`).join(',');
|
|
394
|
-
if (signature === reportedExposure)
|
|
395
|
-
|
|
421
|
+
if (signature === reportedExposure || deliveredExposureSignatures.has(signature) ||
|
|
422
|
+
exposureQueue.some((item) => item.signature === signature)) return;
|
|
396
423
|
|
|
397
424
|
const context = opts.getContext?.() ?? {};
|
|
398
425
|
const assignments: Record<string, string> = {};
|
|
399
426
|
for (const entry of experiments) assignments[entry.key] = entry.variant;
|
|
400
427
|
|
|
428
|
+
exposureQueue.push({
|
|
429
|
+
reportId: randomDeliveryId('exp'),
|
|
430
|
+
signature,
|
|
431
|
+
platform: context.platform,
|
|
432
|
+
appVersion: context.appVersion,
|
|
433
|
+
assignments,
|
|
434
|
+
attempts: 0,
|
|
435
|
+
});
|
|
436
|
+
if (exposureQueue.length > MAX_EXPOSURE_QUEUE) {
|
|
437
|
+
exposureQueue = exposureQueue.slice(-MAX_EXPOSURE_QUEUE);
|
|
438
|
+
}
|
|
439
|
+
persistExposureQueue(opts, exposureQueue);
|
|
440
|
+
void flushExposures();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Flushes the durable exposure outbox. Safe to call on launch, resume or manually. */
|
|
444
|
+
export async function flushExposures(): Promise<number> {
|
|
445
|
+
if (!options || exposureSending || exposureQueue.length === 0 || Date.now() < exposureNextAttemptAt) return 0;
|
|
446
|
+
|
|
447
|
+
const opts = options;
|
|
448
|
+
const requestGeneration = configGeneration;
|
|
449
|
+
const item = exposureQueue[0]!;
|
|
450
|
+
exposureSending = true;
|
|
451
|
+
|
|
452
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
453
|
+
const timeout = controller
|
|
454
|
+
? setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
455
|
+
: null;
|
|
456
|
+
|
|
401
457
|
try {
|
|
402
|
-
await fetch(`${opts.url.replace(/\/+$/, '')}/exposure`, {
|
|
458
|
+
const response = await fetch(`${opts.url.replace(/\/+$/, '')}/exposure`, {
|
|
403
459
|
method: 'POST',
|
|
404
460
|
headers: { 'Content-Type': 'application/json' },
|
|
405
461
|
body: JSON.stringify({
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
462
|
+
reportId: item.reportId,
|
|
463
|
+
platform: item.platform,
|
|
464
|
+
appVersion: item.appVersion,
|
|
465
|
+
assignments: item.assignments,
|
|
409
466
|
}),
|
|
467
|
+
signal: controller?.signal,
|
|
410
468
|
});
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if (options === opts)
|
|
469
|
+
if (!response.ok) throw new Error(`Pulse exposure HTTP ${response.status}`);
|
|
470
|
+
|
|
471
|
+
if (requestGeneration === configGeneration && options === opts) {
|
|
472
|
+
exposureQueue = exposureQueue.filter((queued) => queued.reportId !== item.reportId);
|
|
473
|
+
reportedExposure = item.signature;
|
|
474
|
+
deliveredExposureSignatures.add(item.signature);
|
|
475
|
+
persistDeliveredExposureSignatures(opts);
|
|
476
|
+
exposureNextAttemptAt = 0;
|
|
477
|
+
persistExposureQueue(opts, exposureQueue);
|
|
478
|
+
if (exposureQueue.length > 0) void flushExposures();
|
|
479
|
+
} else {
|
|
480
|
+
removeExposureFromStoredQueue(opts, item.reportId);
|
|
481
|
+
markStoredExposureDelivered(opts, item.signature);
|
|
482
|
+
}
|
|
483
|
+
return 1;
|
|
484
|
+
} catch (error) {
|
|
485
|
+
if (requestGeneration === configGeneration && options === opts) {
|
|
486
|
+
exposureQueue = exposureQueue.map((queued) => queued.reportId === item.reportId
|
|
487
|
+
? { ...queued, attempts: queued.attempts + 1 }
|
|
488
|
+
: queued);
|
|
489
|
+
const attempts = Math.min(item.attempts + 1, 6);
|
|
490
|
+
const base = Math.min(60_000, 1_000 * 2 ** Math.max(0, attempts - 1));
|
|
491
|
+
exposureNextAttemptAt = Date.now() + base;
|
|
492
|
+
persistExposureQueue(opts, exposureQueue);
|
|
493
|
+
}
|
|
494
|
+
opts.onError?.(error);
|
|
495
|
+
return 0;
|
|
496
|
+
} finally {
|
|
497
|
+
if (timeout) clearTimeout(timeout);
|
|
498
|
+
if (requestGeneration === configGeneration) exposureSending = false;
|
|
414
499
|
}
|
|
415
500
|
}
|
|
416
501
|
|
|
502
|
+
export function pendingExposureCount(): number {
|
|
503
|
+
return exposureQueue.length;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function getExposureDeliveryInfo(): { queueDepth: number; sending: boolean; nextAttemptAt: number } {
|
|
507
|
+
return { queueDepth: exposureQueue.length, sending: exposureSending, nextAttemptAt: exposureNextAttemptAt };
|
|
508
|
+
}
|
|
509
|
+
|
|
417
510
|
/**
|
|
418
511
|
* Verifies the exact unsigned JSON object the server signs. A configured verifier
|
|
419
512
|
* also rejects a bad optional signature: accepting a payload that claims to be
|
|
@@ -587,6 +680,102 @@ function stopPolling(): void {
|
|
|
587
680
|
}
|
|
588
681
|
}
|
|
589
682
|
|
|
683
|
+
function startExposureDelivery(): void {
|
|
684
|
+
stopExposureDelivery();
|
|
685
|
+
if (options?.reportExposure !== true) return;
|
|
686
|
+
exposureTimer = setInterval(() => void flushExposures(), DEFAULT_EXPOSURE_RETRY_MS);
|
|
687
|
+
(exposureTimer as unknown as { unref?: () => void }).unref?.();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function stopExposureDelivery(): void {
|
|
691
|
+
if (exposureTimer) clearInterval(exposureTimer);
|
|
692
|
+
exposureTimer = null;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function exposureStorageKey(opts: ConfigOptions): string {
|
|
696
|
+
if (opts.exposureStorageKey?.trim()) return opts.exposureStorageKey.trim();
|
|
697
|
+
if (opts.storageKey?.trim()) return `${opts.storageKey.trim()}.exposures`;
|
|
698
|
+
return DEFAULT_EXPOSURE_STORAGE_KEY;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function deliveredExposureStorageKey(opts: ConfigOptions): string {
|
|
702
|
+
return `${exposureStorageKey(opts)}.delivered`;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function readExposureQueue(opts: ConfigOptions): QueuedExposure[] {
|
|
706
|
+
if (!opts.storage) return [];
|
|
707
|
+
try {
|
|
708
|
+
const raw = opts.storage.getString(exposureStorageKey(opts));
|
|
709
|
+
if (!raw) return [];
|
|
710
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
711
|
+
if (!Array.isArray(parsed)) return [];
|
|
712
|
+
return parsed.filter((item): item is QueuedExposure => Boolean(
|
|
713
|
+
item && typeof item === 'object' &&
|
|
714
|
+
typeof (item as QueuedExposure).reportId === 'string' &&
|
|
715
|
+
typeof (item as QueuedExposure).signature === 'string' &&
|
|
716
|
+
typeof (item as QueuedExposure).assignments === 'object' &&
|
|
717
|
+
typeof (item as QueuedExposure).attempts === 'number',
|
|
718
|
+
)).slice(-MAX_EXPOSURE_QUEUE);
|
|
719
|
+
} catch {
|
|
720
|
+
return [];
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function persistExposureQueue(opts: ConfigOptions, queued: QueuedExposure[]): void {
|
|
725
|
+
if (!opts.storage) return;
|
|
726
|
+
try {
|
|
727
|
+
opts.storage.set(exposureStorageKey(opts), JSON.stringify(queued));
|
|
728
|
+
} catch {
|
|
729
|
+
// Exposure delivery is diagnostic and may never make config adoption fail.
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function readDeliveredExposureSignatures(opts: ConfigOptions): Set<string> {
|
|
734
|
+
if (!opts.storage) return new Set();
|
|
735
|
+
try {
|
|
736
|
+
const raw = opts.storage.getString(deliveredExposureStorageKey(opts));
|
|
737
|
+
if (!raw) return new Set();
|
|
738
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
739
|
+
if (!Array.isArray(parsed)) return new Set();
|
|
740
|
+
return new Set(parsed.filter((value): value is string => typeof value === 'string').slice(-100));
|
|
741
|
+
} catch {
|
|
742
|
+
return new Set();
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function persistDeliveredExposureSignatures(opts: ConfigOptions): void {
|
|
747
|
+
if (!opts.storage) return;
|
|
748
|
+
try {
|
|
749
|
+
const values = [...deliveredExposureSignatures].slice(-100);
|
|
750
|
+
opts.storage.set(deliveredExposureStorageKey(opts), JSON.stringify(values));
|
|
751
|
+
} catch {
|
|
752
|
+
// The server-side reportId still protects retries in this process.
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function markStoredExposureDelivered(opts: ConfigOptions, signature: string): void {
|
|
757
|
+
if (!opts.storage) return;
|
|
758
|
+
try {
|
|
759
|
+
const stored = readDeliveredExposureSignatures(opts);
|
|
760
|
+
stored.add(signature);
|
|
761
|
+
opts.storage.set(deliveredExposureStorageKey(opts), JSON.stringify([...stored].slice(-100)));
|
|
762
|
+
} catch {
|
|
763
|
+
// A later server retry remains idempotent through its reportId.
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function removeExposureFromStoredQueue(opts: ConfigOptions, reportId: string): void {
|
|
768
|
+
if (!opts.storage) return;
|
|
769
|
+
const queued = readExposureQueue(opts).filter((item) => item.reportId !== reportId);
|
|
770
|
+
persistExposureQueue(opts, queued);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function randomDeliveryId(prefix: string): string {
|
|
774
|
+
const cryptoLike = globalThis.crypto as { randomUUID?: () => string } | undefined;
|
|
775
|
+
if (typeof cryptoLike?.randomUUID === 'function') return `${prefix}-${cryptoLike.randomUUID()}`;
|
|
776
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 14)}`;
|
|
777
|
+
}
|
|
778
|
+
|
|
590
779
|
// ─── Reads ────────────────────────────────────────────────────────────────────
|
|
591
780
|
|
|
592
781
|
/** The served value, or the registered default. Never a type's zero value. */
|
|
@@ -714,8 +903,13 @@ export function resetConfigForTests(): void {
|
|
|
714
903
|
inFlight = null;
|
|
715
904
|
pending = null;
|
|
716
905
|
reportedExposure = null;
|
|
906
|
+
exposureQueue = [];
|
|
907
|
+
deliveredExposureSignatures = new Set();
|
|
908
|
+
exposureSending = false;
|
|
909
|
+
exposureNextAttemptAt = 0;
|
|
717
910
|
listeners.clear();
|
|
718
911
|
stopPolling();
|
|
912
|
+
stopExposureDelivery();
|
|
719
913
|
}
|
|
720
914
|
|
|
721
915
|
// ─── Internals ────────────────────────────────────────────────────────────────
|
package/src/decisions.ts
CHANGED
|
@@ -45,6 +45,14 @@ export interface PulseDecisionClientOptions {
|
|
|
45
45
|
/** Safe default. User-facing execution needs an explicit per-app opt-in. */
|
|
46
46
|
executionMode?: 'shadow-only' | 'allow-live';
|
|
47
47
|
handlers?: Record<string, DecisionHandler>;
|
|
48
|
+
/** Persists terminal outcomes across process death and offline periods. */
|
|
49
|
+
storage?: ConfigStorage;
|
|
50
|
+
/** App-scoped outcome outbox key. Defaults to one derived from appSlug. */
|
|
51
|
+
storageKey?: string;
|
|
52
|
+
/** Retry tick for pending outcomes. Default 10s. */
|
|
53
|
+
flushIntervalMs?: number;
|
|
54
|
+
/** Bound on decisions awaiting delivery. Default 100. */
|
|
55
|
+
maxQueue?: number;
|
|
48
56
|
onError?: (error: unknown) => void;
|
|
49
57
|
}
|
|
50
58
|
|
|
@@ -57,8 +65,22 @@ export interface DecideOptions {
|
|
|
57
65
|
export interface PulseDecisionClient {
|
|
58
66
|
decide(trigger: string, options?: DecideOptions): Promise<PulseDecision | null>;
|
|
59
67
|
reportOutcome(decisionId: string, outcome: DecisionOutcome, metadata?: Record<string, unknown>): Promise<boolean>;
|
|
68
|
+
flushOutcomes(): Promise<number>;
|
|
69
|
+
pendingOutcomeCount(): number;
|
|
70
|
+
dispose(): Promise<void>;
|
|
60
71
|
}
|
|
61
72
|
|
|
73
|
+
interface QueuedOutcome {
|
|
74
|
+
decisionId: string;
|
|
75
|
+
outcome: DecisionOutcome;
|
|
76
|
+
metadata?: Record<string, unknown>;
|
|
77
|
+
revision: string;
|
|
78
|
+
attempts: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DEFAULT_OUTCOME_FLUSH_MS = 10_000;
|
|
82
|
+
const DEFAULT_OUTCOME_MAX_QUEUE = 100;
|
|
83
|
+
|
|
62
84
|
/** Generic decision protocol. Product-specific action handlers remain in each app. */
|
|
63
85
|
export function createPulseDecisionClient(options: PulseDecisionClientOptions): PulseDecisionClient {
|
|
64
86
|
const fetcher = options.fetch ?? globalThis.fetch;
|
|
@@ -66,26 +88,83 @@ export function createPulseDecisionClient(options: PulseDecisionClientOptions):
|
|
|
66
88
|
const slug = encodeURIComponent(options.appSlug);
|
|
67
89
|
const timeoutMs = Math.max(250, options.timeoutMs ?? 3_000);
|
|
68
90
|
const executionMode = options.executionMode ?? 'shadow-only';
|
|
91
|
+
const storageKey = options.storageKey?.trim() || `pulse.${options.appSlug.trim()}.outcomes.v1`;
|
|
92
|
+
const maxQueue = Math.max(1, options.maxQueue ?? DEFAULT_OUTCOME_MAX_QUEUE);
|
|
93
|
+
let outcomeQueue = readOutcomeQueue(options.storage, storageKey, maxQueue);
|
|
94
|
+
let outcomeSending = false;
|
|
95
|
+
let outcomeNextAttemptAt = 0;
|
|
96
|
+
let disposed = false;
|
|
69
97
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
98
|
+
const persistOutcomes = (): void => {
|
|
99
|
+
if (!options.storage) return;
|
|
100
|
+
try { options.storage.set(storageKey, JSON.stringify(outcomeQueue)); }
|
|
101
|
+
catch {
|
|
102
|
+
// A storage failure may reduce durability, but decision execution itself must
|
|
103
|
+
// remain available and the caller still receives the delivery result.
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const flushOutcomes = async (): Promise<number> => {
|
|
108
|
+
if (disposed || outcomeSending || outcomeQueue.length === 0 || Date.now() < outcomeNextAttemptAt) return 0;
|
|
109
|
+
outcomeSending = true;
|
|
110
|
+
const queued = outcomeQueue[0]!;
|
|
75
111
|
try {
|
|
76
112
|
const response = await timedFetch(fetcher,
|
|
77
|
-
`${base}/pulse/decisions/${slug}/${encodeURIComponent(decisionId)}/outcome`, {
|
|
113
|
+
`${base}/pulse/decisions/${slug}/${encodeURIComponent(queued.decisionId)}/outcome`, {
|
|
78
114
|
method: 'POST',
|
|
79
115
|
headers: { 'Content-Type': 'application/json' },
|
|
80
|
-
body: JSON.stringify({
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
outcome: queued.outcome,
|
|
118
|
+
...(queued.metadata ? { metadata: queued.metadata } : {}),
|
|
119
|
+
}),
|
|
81
120
|
}, timeoutMs);
|
|
82
|
-
|
|
121
|
+
if (!response.ok) throw new Error(`Pulse outcome HTTP ${response.status}`);
|
|
122
|
+
|
|
123
|
+
// A newer terminal state may have replaced this one while the request was in
|
|
124
|
+
// flight (accepted -> converted). Remove only the exact revision delivered.
|
|
125
|
+
outcomeQueue = outcomeQueue.filter((item) =>
|
|
126
|
+
item.decisionId !== queued.decisionId || item.revision !== queued.revision);
|
|
127
|
+
outcomeNextAttemptAt = 0;
|
|
128
|
+
persistOutcomes();
|
|
129
|
+
return 1;
|
|
83
130
|
} catch (error) {
|
|
131
|
+
const current = outcomeQueue.find((item) => item.decisionId === queued.decisionId);
|
|
132
|
+
if (current?.revision === queued.revision) current.attempts += 1;
|
|
133
|
+
const attempts = Math.min((current?.attempts ?? queued.attempts + 1), 6);
|
|
134
|
+
const baseDelay = Math.min(60_000, 1_000 * 2 ** Math.max(0, attempts - 1));
|
|
135
|
+
outcomeNextAttemptAt = Date.now() + baseDelay;
|
|
136
|
+
persistOutcomes();
|
|
84
137
|
options.onError?.(error);
|
|
85
|
-
return
|
|
138
|
+
return 0;
|
|
139
|
+
} finally {
|
|
140
|
+
outcomeSending = false;
|
|
141
|
+
if (!disposed && outcomeNextAttemptAt === 0 && outcomeQueue.length > 0) void flushOutcomes();
|
|
86
142
|
}
|
|
87
143
|
};
|
|
88
144
|
|
|
145
|
+
const reportOutcome = async (
|
|
146
|
+
decisionId: string,
|
|
147
|
+
outcome: DecisionOutcome,
|
|
148
|
+
metadata?: Record<string, unknown>,
|
|
149
|
+
): Promise<boolean> => {
|
|
150
|
+
if (!decisionId.trim() || disposed) return false;
|
|
151
|
+
const next: QueuedOutcome = {
|
|
152
|
+
decisionId,
|
|
153
|
+
outcome,
|
|
154
|
+
metadata,
|
|
155
|
+
revision: randomKey(),
|
|
156
|
+
attempts: 0,
|
|
157
|
+
};
|
|
158
|
+
const existing = outcomeQueue.findIndex((item) => item.decisionId === decisionId);
|
|
159
|
+
if (existing >= 0) outcomeQueue[existing] = next;
|
|
160
|
+
else outcomeQueue.push(next);
|
|
161
|
+
outcomeQueue = outcomeQueue.slice(-maxQueue);
|
|
162
|
+
outcomeNextAttemptAt = 0;
|
|
163
|
+
persistOutcomes();
|
|
164
|
+
await flushOutcomes();
|
|
165
|
+
return !outcomeQueue.some((item) => item.decisionId === decisionId && item.revision === next.revision);
|
|
166
|
+
};
|
|
167
|
+
|
|
89
168
|
const decide = async (trigger: string, request: DecideOptions = {}): Promise<PulseDecision | null> => {
|
|
90
169
|
try {
|
|
91
170
|
const context = await options.getContext();
|
|
@@ -133,7 +212,45 @@ export function createPulseDecisionClient(options: PulseDecisionClientOptions):
|
|
|
133
212
|
}
|
|
134
213
|
};
|
|
135
214
|
|
|
136
|
-
|
|
215
|
+
const timer = setInterval(() => void flushOutcomes(), options.flushIntervalMs ?? DEFAULT_OUTCOME_FLUSH_MS);
|
|
216
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
217
|
+
void flushOutcomes();
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
decide,
|
|
221
|
+
reportOutcome,
|
|
222
|
+
flushOutcomes,
|
|
223
|
+
pendingOutcomeCount: () => outcomeQueue.length,
|
|
224
|
+
dispose: async () => {
|
|
225
|
+
clearInterval(timer);
|
|
226
|
+
await flushOutcomes();
|
|
227
|
+
disposed = true;
|
|
228
|
+
persistOutcomes();
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function readOutcomeQueue(
|
|
234
|
+
storage: ConfigStorage | undefined,
|
|
235
|
+
key: string,
|
|
236
|
+
maxQueue: number,
|
|
237
|
+
): QueuedOutcome[] {
|
|
238
|
+
if (!storage) return [];
|
|
239
|
+
try {
|
|
240
|
+
const raw = storage.getString(key);
|
|
241
|
+
if (!raw) return [];
|
|
242
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
243
|
+
if (!Array.isArray(parsed)) return [];
|
|
244
|
+
return parsed.filter((item): item is QueuedOutcome => Boolean(
|
|
245
|
+
item && typeof item === 'object' &&
|
|
246
|
+
typeof (item as QueuedOutcome).decisionId === 'string' &&
|
|
247
|
+
typeof (item as QueuedOutcome).outcome === 'string' &&
|
|
248
|
+
typeof (item as QueuedOutcome).revision === 'string' &&
|
|
249
|
+
typeof (item as QueuedOutcome).attempts === 'number',
|
|
250
|
+
)).slice(-maxQueue);
|
|
251
|
+
} catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
137
254
|
}
|
|
138
255
|
|
|
139
256
|
function parseDecision(raw: unknown): PulseDecision {
|
|
@@ -177,3 +294,4 @@ function randomKey(): string {
|
|
|
177
294
|
if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID();
|
|
178
295
|
return `moment-${Date.now()}-${Math.random().toString(36).slice(2, 14)}`;
|
|
179
296
|
}
|
|
297
|
+
import type { ConfigStorage } from './config';
|
package/src/init.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
getConfigNumber,
|
|
37
37
|
getConfigString,
|
|
38
38
|
getConfigJson,
|
|
39
|
+
getExposureDeliveryInfo,
|
|
39
40
|
} from './config';
|
|
40
41
|
import {
|
|
41
42
|
configureTracking,
|
|
@@ -100,6 +101,9 @@ export interface InitPulseOptions {
|
|
|
100
101
|
/** Foreground poll interval for the config. 0 disables it; launch and resume still fetch. */
|
|
101
102
|
pollIntervalMs?: number;
|
|
102
103
|
|
|
104
|
+
/** Report experiment assignments through the durable outbox. Defaults to true. */
|
|
105
|
+
reportExposure?: boolean;
|
|
106
|
+
|
|
103
107
|
/** How often queued events are sent. Default 10s. */
|
|
104
108
|
flushIntervalMs?: number;
|
|
105
109
|
|
|
@@ -119,6 +123,7 @@ export interface PulseHealth {
|
|
|
119
123
|
deviceId: string;
|
|
120
124
|
config: ReturnType<typeof getConfigInfo>;
|
|
121
125
|
events: ReturnType<typeof getTrackingInfo>;
|
|
126
|
+
exposures: ReturnType<typeof getExposureDeliveryInfo>;
|
|
122
127
|
analyticsConsent: boolean;
|
|
123
128
|
}
|
|
124
129
|
|
|
@@ -196,6 +201,8 @@ export function initPulse(opts: InitPulseOptions): PulseClient {
|
|
|
196
201
|
defaults: opts.defaults,
|
|
197
202
|
storage: opts.storage,
|
|
198
203
|
storageKey: `pulse.${slug}.config.v1`,
|
|
204
|
+
exposureStorageKey: `pulse.${slug}.exposures.v1`,
|
|
205
|
+
reportExposure: opts.reportExposure ?? true,
|
|
199
206
|
getContext,
|
|
200
207
|
pollIntervalMs: opts.pollIntervalMs,
|
|
201
208
|
onError: opts.onError,
|
|
@@ -260,7 +267,14 @@ export function initPulse(opts: InitPulseOptions): PulseClient {
|
|
|
260
267
|
try { opts.storage?.set(deviceStorageKey, deviceId); } catch { /* memory-only fallback */ }
|
|
261
268
|
return deviceId;
|
|
262
269
|
},
|
|
263
|
-
health: () => ({
|
|
270
|
+
health: () => ({
|
|
271
|
+
appSlug: slug,
|
|
272
|
+
deviceId,
|
|
273
|
+
config: getConfigInfo(),
|
|
274
|
+
events: getTrackingInfo(),
|
|
275
|
+
exposures: getExposureDeliveryInfo(),
|
|
276
|
+
analyticsConsent: hasConsent(),
|
|
277
|
+
}),
|
|
264
278
|
config: {
|
|
265
279
|
boolean: getConfigBoolean,
|
|
266
280
|
number: getConfigNumber,
|
|
@@ -297,6 +311,7 @@ export async function initPulseAsync(
|
|
|
297
311
|
`pulse.${slug}.device-id`,
|
|
298
312
|
`pulse.${slug}.config.v1`,
|
|
299
313
|
`pulse.${slug}.events.v1`,
|
|
314
|
+
`pulse.${slug}.exposures.v1`,
|
|
300
315
|
DEVICE_ID_KEY,
|
|
301
316
|
];
|
|
302
317
|
const loaded = await Promise.all(keys.map(async (key) => [key, await opts.storage.getItem(key)] as const));
|