pulse-updates 1.1.1 → 1.2.0

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.
Files changed (64) hide show
  1. package/README.md +50 -10
  2. package/SECURITY.md +42 -0
  3. package/android/src/main/java/app/pulse/updates/PulseController.kt +6 -2
  4. package/android/src/main/java/app/pulse/updates/PulseUpdatesModule.kt +25 -0
  5. package/app.plugin.js +47 -0
  6. package/ios/PulseUpdates/PulseController.swift +7 -2
  7. package/ios/PulseUpdates/PulseUpdates.m +7 -0
  8. package/ios/PulseUpdates/PulseUpdates.swift +22 -0
  9. package/lib/commonjs/NativePulseUpdates.js.map +1 -1
  10. package/lib/commonjs/PulseUpdates.js +36 -0
  11. package/lib/commonjs/PulseUpdates.js.map +1 -1
  12. package/lib/commonjs/config.js +175 -21
  13. package/lib/commonjs/config.js.map +1 -1
  14. package/lib/commonjs/decisions.js +128 -0
  15. package/lib/commonjs/decisions.js.map +1 -0
  16. package/lib/commonjs/index.js +12 -0
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/commonjs/init.js +153 -13
  19. package/lib/commonjs/init.js.map +1 -1
  20. package/lib/commonjs/track.js +154 -12
  21. package/lib/commonjs/track.js.map +1 -1
  22. package/lib/commonjs/usePulseUpdates.js +21 -16
  23. package/lib/commonjs/usePulseUpdates.js.map +1 -1
  24. package/lib/module/NativePulseUpdates.js.map +1 -1
  25. package/lib/module/PulseUpdates.js +33 -0
  26. package/lib/module/PulseUpdates.js.map +1 -1
  27. package/lib/module/config.js +174 -21
  28. package/lib/module/config.js.map +1 -1
  29. package/lib/module/decisions.js +122 -0
  30. package/lib/module/decisions.js.map +1 -0
  31. package/lib/module/index.js +1 -0
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/module/init.js +154 -15
  34. package/lib/module/init.js.map +1 -1
  35. package/lib/module/track.js +152 -12
  36. package/lib/module/track.js.map +1 -1
  37. package/lib/module/usePulseUpdates.js +21 -16
  38. package/lib/module/usePulseUpdates.js.map +1 -1
  39. package/lib/typescript/NativePulseUpdates.d.ts +1 -0
  40. package/lib/typescript/NativePulseUpdates.d.ts.map +1 -1
  41. package/lib/typescript/PulseUpdates.d.ts +20 -0
  42. package/lib/typescript/PulseUpdates.d.ts.map +1 -1
  43. package/lib/typescript/config.d.ts +22 -0
  44. package/lib/typescript/config.d.ts.map +1 -1
  45. package/lib/typescript/decisions.d.ts +55 -0
  46. package/lib/typescript/decisions.d.ts.map +1 -0
  47. package/lib/typescript/index.d.ts +1 -0
  48. package/lib/typescript/index.d.ts.map +1 -1
  49. package/lib/typescript/init.d.ts +51 -4
  50. package/lib/typescript/init.d.ts.map +1 -1
  51. package/lib/typescript/track.d.ts +29 -1
  52. package/lib/typescript/track.d.ts.map +1 -1
  53. package/lib/typescript/usePulseUpdates.d.ts.map +1 -1
  54. package/logo.png +0 -0
  55. package/package.json +14 -3
  56. package/scripts/publish.mjs +68 -3
  57. package/src/NativePulseUpdates.ts +1 -0
  58. package/src/PulseUpdates.ts +54 -0
  59. package/src/config.ts +234 -21
  60. package/src/decisions.ts +179 -0
  61. package/src/index.ts +1 -0
  62. package/src/init.ts +227 -12
  63. package/src/track.ts +191 -13
  64. package/src/usePulseUpdates.ts +21 -16
package/src/track.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * everybody sees.
21
21
  */
22
22
 
23
- import type { ConfigContext } from './config';
23
+ import type { ConfigContext, ConfigStorage } from './config';
24
24
 
25
25
  export interface TrackOptions {
26
26
  /**
@@ -43,9 +43,35 @@ export interface TrackOptions {
43
43
  /** Events held before the oldest are dropped. Default 500. */
44
44
  maxQueue?: number;
45
45
 
46
+ /** Persists the outbox across process death. Use the same storage passed to initPulse. */
47
+ storage?: ConfigStorage;
48
+
49
+ /** App-scoped persistence key. initPulse supplies one automatically. */
50
+ storageKey?: string;
51
+
52
+ /** Per-request timeout. Default 10s. */
53
+ timeoutMs?: number;
54
+
55
+ /** Maximum delivery attempts before an event is dropped. Default 10. */
56
+ maxAttempts?: number;
57
+
58
+ /** Return false until analytics consent exists. No event is retained before consent. */
59
+ hasConsent?: () => boolean;
60
+
61
+ /** Only these property keys may leave the process. */
62
+ propertyAllowlist?: readonly string[];
63
+
64
+ /** These allowed properties are replaced with "[REDACTED]". */
65
+ redactProperties?: readonly string[];
66
+
67
+ /** Foreground/background lifecycle used for a best-effort final flush. */
68
+ appState?: { addEventListener: (type: 'change', handler: (state: string) => void) => { remove: () => void } };
69
+
46
70
  /** Called with the event keys the server did not recognise. */
47
71
  onIgnored?: (events: string[]) => void;
48
72
 
73
+ onDropped?: (count: number, reason: 'queue-full' | 'max-attempts' | 'no-consent') => void;
74
+
49
75
  onError?: (error: unknown) => void;
50
76
  }
51
77
 
@@ -56,19 +82,27 @@ export interface TrackedEventInput {
56
82
  }
57
83
 
58
84
  interface QueuedEvent {
85
+ id: string;
59
86
  event: string;
60
87
  time: string;
61
88
  props: Record<string, string>;
89
+ attempts: number;
62
90
  }
63
91
 
64
92
  const DEFAULT_FLUSH_MS = 10_000;
65
93
  const DEFAULT_MAX_BATCH = 100;
66
94
  const DEFAULT_MAX_QUEUE = 500;
95
+ const DEFAULT_TIMEOUT_MS = 10_000;
96
+ const DEFAULT_MAX_ATTEMPTS = 10;
97
+ const DEFAULT_STORAGE_KEY = 'pulse.events.v1';
67
98
 
68
99
  let options: TrackOptions | null = null;
69
100
  let queue: QueuedEvent[] = [];
70
101
  let timer: ReturnType<typeof setInterval> | null = null;
71
102
  let sending = false;
103
+ let nextAttemptAt = 0;
104
+ let lifecycleSubscription: { remove: () => void } | null = null;
105
+ let trackingGeneration = 0;
72
106
 
73
107
  /** Turns a config url into the track url; leaves an explicit track url alone. */
74
108
  function trackUrl(url: string): string {
@@ -79,8 +113,14 @@ function trackUrl(url: string): string {
79
113
  }
80
114
 
81
115
  export function configureTracking(opts: TrackOptions): void {
116
+ disposeTracking();
82
117
  options = opts;
118
+ queue = readQueue(opts);
119
+ nextAttemptAt = 0;
83
120
  startTimer();
121
+ lifecycleSubscription = opts.appState?.addEventListener('change', (state) => {
122
+ if (state !== 'active') void flushEvents();
123
+ }) ?? null;
84
124
  }
85
125
 
86
126
  /**
@@ -90,17 +130,29 @@ export function configureTracking(opts: TrackOptions): void {
90
130
  */
91
131
  export function track(event: string, props?: TrackedEventInput['props'], time?: Date): void {
92
132
  if (!options || !event) return;
133
+ if (options.hasConsent?.() === false) {
134
+ options.onDropped?.(1, 'no-consent');
135
+ return;
136
+ }
93
137
 
94
138
  const flat: Record<string, string> = {};
139
+ const allow = options.propertyAllowlist ? new Set(options.propertyAllowlist) : null;
140
+ const redact = new Set(options.redactProperties ?? []);
95
141
  for (const [key, value] of Object.entries(props ?? {})) {
96
142
  if (value === null || value === undefined) continue;
97
- flat[key] = typeof value === 'string' ? value : String(value);
143
+ if (allow && !allow.has(key)) continue;
144
+ flat[key] = redact.has(key) ? '[REDACTED]' : (typeof value === 'string' ? value : String(value));
98
145
  }
99
146
 
100
- queue.push({ event, time: (time ?? new Date()).toISOString(), props: flat });
147
+ queue.push({ id: randomEventId(), event, time: (time ?? new Date()).toISOString(), props: flat, attempts: 0 });
101
148
 
102
149
  const maxQueue = options.maxQueue ?? DEFAULT_MAX_QUEUE;
103
- if (queue.length > maxQueue) queue = queue.slice(queue.length - maxQueue);
150
+ if (queue.length > maxQueue) {
151
+ const dropped = queue.length - maxQueue;
152
+ queue = queue.slice(dropped);
153
+ options.onDropped?.(dropped, 'queue-full');
154
+ }
155
+ persistQueue();
104
156
 
105
157
  if (queue.length >= (options.maxBatch ?? DEFAULT_MAX_BATCH)) void flushEvents();
106
158
  }
@@ -113,9 +165,10 @@ export function track(event: string, props?: TrackedEventInput['props'], time?:
113
165
  * after launch, when the first events of a session are the ones a funnel needs most.
114
166
  */
115
167
  export async function flushEvents(): Promise<number> {
116
- if (!options || sending || queue.length === 0) return 0;
168
+ if (!options || sending || queue.length === 0 || Date.now() < nextAttemptAt) return 0;
117
169
 
118
170
  const opts = options;
171
+ const requestGeneration = trackingGeneration;
119
172
  const ctx = opts.getContext?.() ?? {};
120
173
 
121
174
  // Without a device id the server cannot put the event in an arm, and an empty id
@@ -125,8 +178,14 @@ export async function flushEvents(): Promise<number> {
125
178
 
126
179
  const batch = queue.slice(0, opts.maxBatch ?? DEFAULT_MAX_BATCH);
127
180
  queue = queue.slice(batch.length);
181
+ persistQueue();
128
182
  sending = true;
129
183
 
184
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
185
+ const timeout = controller
186
+ ? setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
187
+ : null;
188
+
130
189
  try {
131
190
  const response = await fetch(trackUrl(opts.url), {
132
191
  method: 'POST',
@@ -136,26 +195,36 @@ export async function flushEvents(): Promise<number> {
136
195
  userId: ctx.userId,
137
196
  platform: ctx.platform,
138
197
  appVersion: ctx.appVersion,
139
- events: batch.map((e) => ({ event: e.event, time: e.time, props: e.props })),
198
+ events: batch.map((e) => ({ id: e.id, event: e.event, time: e.time, props: e.props })),
140
199
  }),
200
+ signal: controller?.signal,
141
201
  });
142
202
 
143
203
  if (!response.ok) {
144
204
  // Rate limited or briefly down: keep the events, try on the next tick.
145
- queue = [...batch, ...queue];
205
+ if (requestGeneration === trackingGeneration && options === opts) {
206
+ requeueWithBackoff(batch, response.status);
207
+ } else {
208
+ restoreStaleBatch(opts, batch, response.status);
209
+ }
146
210
  if (response.status !== 429) opts.onError?.(new Error(`Pulse track failed: ${response.status}`));
147
211
  return 0;
148
212
  }
149
213
 
150
214
  const body = (await response.json()) as { accepted?: number; ignored?: string[] };
215
+ if (requestGeneration !== trackingGeneration || options !== opts) return body.accepted ?? 0;
151
216
  if (body.ignored && body.ignored.length > 0) opts.onIgnored?.(body.ignored);
217
+ nextAttemptAt = 0;
218
+ persistQueue();
152
219
  return body.accepted ?? 0;
153
220
  } catch (error) {
154
- queue = [...batch, ...queue];
221
+ if (requestGeneration === trackingGeneration && options === opts) requeueWithBackoff(batch);
222
+ else restoreStaleBatch(opts, batch);
155
223
  opts.onError?.(error);
156
224
  return 0;
157
225
  } finally {
158
- sending = false;
226
+ if (timeout) clearTimeout(timeout);
227
+ if (requestGeneration === trackingGeneration) sending = false;
159
228
  }
160
229
  }
161
230
 
@@ -164,14 +233,32 @@ export function pendingEventCount(): number {
164
233
  return queue.length;
165
234
  }
166
235
 
236
+ export function getTrackingInfo(): {
237
+ queueDepth: number;
238
+ sending: boolean;
239
+ nextAttemptAt: number;
240
+ } {
241
+ return { queueDepth: queue.length, sending, nextAttemptAt };
242
+ }
243
+
244
+ /** Stop timers and listeners while preserving the durable outbox. */
245
+ export function disposeTracking(): void {
246
+ trackingGeneration += 1;
247
+ if (timer) clearInterval(timer);
248
+ timer = null;
249
+ lifecycleSubscription?.remove();
250
+ lifecycleSubscription = null;
251
+ persistQueue();
252
+ options = null;
253
+ sending = false;
254
+ }
255
+
167
256
  export function stopTracking(): void {
168
- if (timer) {
169
- clearInterval(timer);
170
- timer = null;
171
- }
257
+ disposeTracking();
172
258
  options = null;
173
259
  queue = [];
174
260
  sending = false;
261
+ nextAttemptAt = 0;
175
262
  }
176
263
 
177
264
  function startTimer(): void {
@@ -182,3 +269,94 @@ function startTimer(): void {
182
269
  // and a CLI importing this should still be able to exit.
183
270
  (timer as unknown as { unref?: () => void }).unref?.();
184
271
  }
272
+
273
+ function requeueWithBackoff(batch: QueuedEvent[], status?: number): void {
274
+ if (!options) return;
275
+ const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
276
+ const retryable: QueuedEvent[] = [];
277
+ let dropped = 0;
278
+ for (const event of batch) {
279
+ const next = { ...event, attempts: event.attempts + 1 };
280
+ if (next.attempts >= maxAttempts && status !== 429) dropped++;
281
+ else retryable.push(next);
282
+ }
283
+ queue = [...retryable, ...queue];
284
+ if (dropped > 0) options.onDropped?.(dropped, 'max-attempts');
285
+ const attempts = retryable.reduce((max, event) => Math.max(max, event.attempts), 1);
286
+ const base = Math.min(60_000, 1_000 * 2 ** Math.min(attempts - 1, 6));
287
+ nextAttemptAt = Date.now() + base + Math.floor(Math.random() * Math.max(1, base / 4));
288
+ persistQueue();
289
+ }
290
+
291
+ /**
292
+ * An old request may fail after another app has been configured. Its rows belong in
293
+ * the old app's outbox, never in the new app's in-memory queue. When the same app was
294
+ * merely reconfigured, merge them back into the live queue so it need not restart to
295
+ * see them; otherwise persist directly under the captured app-scoped key.
296
+ */
297
+ function restoreStaleBatch(opts: TrackOptions, batch: QueuedEvent[], status?: number): void {
298
+ const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
299
+ const retryable: QueuedEvent[] = [];
300
+ let dropped = 0;
301
+ for (const event of batch) {
302
+ const next = { ...event, attempts: event.attempts + 1 };
303
+ if (next.attempts >= maxAttempts && status !== 429) dropped++;
304
+ else retryable.push(next);
305
+ }
306
+ if (dropped > 0) opts.onDropped?.(dropped, 'max-attempts');
307
+ if (retryable.length === 0) return;
308
+
309
+ const currentOptions = options;
310
+ const sameOutbox = currentOptions?.storage === opts.storage &&
311
+ currentOptions !== null && storageKey(currentOptions) === storageKey(opts);
312
+ if (sameOutbox) {
313
+ queue = [...retryable, ...queue].slice(-(options?.maxQueue ?? DEFAULT_MAX_QUEUE));
314
+ persistQueue();
315
+ return;
316
+ }
317
+
318
+ if (!opts.storage) return;
319
+ try {
320
+ const stored = readQueue(opts);
321
+ const restored = [...retryable, ...stored].slice(-(opts.maxQueue ?? DEFAULT_MAX_QUEUE));
322
+ opts.storage.set(storageKey(opts), JSON.stringify(restored));
323
+ } catch {
324
+ // Best effort only; a telemetry recovery must not affect the new app session.
325
+ }
326
+ }
327
+
328
+ function storageKey(opts: TrackOptions): string {
329
+ return opts.storageKey?.trim() || DEFAULT_STORAGE_KEY;
330
+ }
331
+
332
+ function readQueue(opts: TrackOptions): QueuedEvent[] {
333
+ if (!opts.storage) return [];
334
+ try {
335
+ const raw = opts.storage.getString(storageKey(opts));
336
+ if (!raw) return [];
337
+ const parsed = JSON.parse(raw) as unknown;
338
+ if (!Array.isArray(parsed)) return [];
339
+ return parsed.filter((item): item is QueuedEvent => Boolean(
340
+ item && typeof item === 'object' && typeof item.id === 'string' &&
341
+ typeof item.event === 'string' && typeof item.time === 'string' &&
342
+ typeof item.props === 'object' && typeof item.attempts === 'number',
343
+ )).slice(-(opts.maxQueue ?? DEFAULT_MAX_QUEUE));
344
+ } catch {
345
+ return [];
346
+ }
347
+ }
348
+
349
+ function persistQueue(): void {
350
+ if (!options?.storage) return;
351
+ try {
352
+ options.storage.set(storageKey(options), JSON.stringify(queue));
353
+ } catch {
354
+ // Telemetry persistence must never become an app failure.
355
+ }
356
+ }
357
+
358
+ function randomEventId(): string {
359
+ const cryptoLike = globalThis.crypto as { randomUUID?: () => string } | undefined;
360
+ if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID();
361
+ return `evt-${Date.now()}-${Math.random().toString(36).slice(2, 14)}`;
362
+ }
@@ -27,6 +27,7 @@ export interface UsePulseUpdatesResult {
27
27
  }
28
28
 
29
29
  export function usePulseUpdates(): UsePulseUpdatesResult {
30
+ const [nativeState, setNativeState] = useState(PulseUpdates.getUpdatesState);
30
31
  const [isChecking, setIsChecking] = useState(false);
31
32
  const [isDownloading, setIsDownloading] = useState(false);
32
33
  const [availableUpdate, setAvailableUpdate] = useState<PulseManifest | null>(null);
@@ -35,11 +36,15 @@ export function usePulseUpdates(): UsePulseUpdatesResult {
35
36
 
36
37
  // Refresh state on mount to get latest values after configure
37
38
  useEffect(() => {
38
- PulseUpdates.refreshStateAsync().catch(() => {});
39
+ const unsubscribe = PulseUpdates.onUpdatesStateChange(() => {
40
+ setNativeState(PulseUpdates.getUpdatesState());
41
+ });
42
+ void PulseUpdates.refreshStateAsync();
43
+ return unsubscribe;
39
44
  }, []);
40
45
 
41
46
  useEffect(() => {
42
- if (!PulseUpdates.isEnabled) return;
47
+ if (!nativeState.isEnabled) return;
43
48
 
44
49
  const subscription = PulseUpdates.addUpdateListener((event) => {
45
50
  switch (event.type) {
@@ -59,10 +64,10 @@ export function usePulseUpdates(): UsePulseUpdatesResult {
59
64
  });
60
65
 
61
66
  return () => subscription.remove();
62
- }, []);
67
+ }, [nativeState.isEnabled]);
63
68
 
64
69
  const checkForUpdate = useCallback(async (): Promise<UpdateCheckResult> => {
65
- if (!PulseUpdates.isEnabled) {
70
+ if (!nativeState.isEnabled) {
66
71
  return { isAvailable: false };
67
72
  }
68
73
 
@@ -84,10 +89,10 @@ export function usePulseUpdates(): UsePulseUpdatesResult {
84
89
  } finally {
85
90
  setIsChecking(false);
86
91
  }
87
- }, []);
92
+ }, [nativeState.isEnabled]);
88
93
 
89
94
  const downloadUpdate = useCallback(async (): Promise<UpdateFetchResult> => {
90
- if (!PulseUpdates.isEnabled) {
95
+ if (!nativeState.isEnabled) {
91
96
  return { isNew: false };
92
97
  }
93
98
 
@@ -107,23 +112,23 @@ export function usePulseUpdates(): UsePulseUpdatesResult {
107
112
  } finally {
108
113
  setIsDownloading(false);
109
114
  }
110
- }, []);
115
+ }, [nativeState.isEnabled]);
111
116
 
112
117
  const reload = useCallback(async (): Promise<void> => {
113
- if (!PulseUpdates.isEnabled) return;
118
+ if (!nativeState.isEnabled) return;
114
119
  await PulseUpdates.reloadAsync();
115
- }, []);
120
+ }, [nativeState.isEnabled]);
116
121
 
117
122
  return {
118
- isEnabled: PulseUpdates.isEnabled,
123
+ isEnabled: nativeState.isEnabled,
119
124
  isChecking,
120
125
  isDownloading,
121
- updateId: PulseUpdates.updateId,
122
- runtimeVersion: PulseUpdates.runtimeVersion,
123
- channel: PulseUpdates.channel,
124
- manifest: PulseUpdates.manifest,
125
- isEmbeddedLaunch: PulseUpdates.isEmbeddedLaunch,
126
- createdAt: PulseUpdates.createdAt,
126
+ updateId: nativeState.updateId,
127
+ runtimeVersion: nativeState.runtimeVersion,
128
+ channel: nativeState.channel,
129
+ manifest: nativeState.manifest,
130
+ isEmbeddedLaunch: nativeState.isEmbeddedLaunch,
131
+ createdAt: nativeState.createdAt,
127
132
  build: PulseUpdates.getUpdateBuild(),
128
133
  availableUpdate,
129
134
  downloadedUpdate,