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/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
- void reportExposure(opts);
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
- async function reportExposure(opts: ConfigOptions): Promise<void> {
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) return;
395
- reportedExposure = signature;
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
- platform: context.platform,
407
- appVersion: context.appVersion,
408
- assignments,
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
- } catch {
412
- // A device that cannot report its arm still has the right arm.
413
- if (options === opts) reportedExposure = signature;
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 reportOutcome = async (
71
- decisionId: string,
72
- outcome: DecisionOutcome,
73
- metadata?: Record<string, unknown>,
74
- ): Promise<boolean> => {
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({ outcome, ...(metadata ? { metadata } : {}) }),
116
+ body: JSON.stringify({
117
+ outcome: queued.outcome,
118
+ ...(queued.metadata ? { metadata: queued.metadata } : {}),
119
+ }),
81
120
  }, timeoutMs);
82
- return response.ok;
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 false;
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
- return { decide, reportOutcome };
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: () => ({ appSlug: slug, deviceId, config: getConfigInfo(), events: getTrackingInfo(), analyticsConsent: hasConsent() }),
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));