@sharib/geopulse-react-native 0.3.4 → 0.5.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.
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ AlertExperience: () => AlertExperience,
33
34
  ApplicationMismatchError: () => ApplicationMismatchError,
34
35
  BannerExperience: () => BannerExperience,
35
36
  CardExperience: () => CardExperience,
@@ -42,7 +43,8 @@ __export(index_exports, {
42
43
  MessageExperience: () => MessageExperience,
43
44
  NetInfoNetworkMonitor: () => NetInfoNetworkMonitor,
44
45
  SimulatedLocationProvider: () => SimulatedLocationProvider,
45
- SimulatedPermissionProvider: () => SimulatedPermissionProvider
46
+ SimulatedPermissionProvider: () => SimulatedPermissionProvider,
47
+ isAlertExperience: () => isAlertExperience
46
48
  });
47
49
  module.exports = __toCommonJS(index_exports);
48
50
  var import_react_native2 = require("react-native");
@@ -60,7 +62,7 @@ var AsyncStorageKeyValueStore = class {
60
62
 
61
63
  // src/config/types.ts
62
64
  var DEFAULT_BACKEND_URL = "https://geofends.onrender.com";
63
- var SDK_VERSION = "0.3.4";
65
+ var SDK_VERSION = "0.5.0";
64
66
 
65
67
  // src/config/key-value-store.ts
66
68
  var InMemoryKeyValueStore = class {
@@ -184,6 +186,12 @@ var DEFAULT_STYLES = {
184
186
  body: { visible: true, color: "#e5e7eb", fontSize: 13, fontWeight: "normal" },
185
187
  button: { visible: false, backgroundColor: "#2563eb", textColor: "#ffffff", borderRadius: 6, position: { top: null, bottom: null, left: null, right: 12 } }
186
188
  },
189
+ alert: {
190
+ container: { backgroundColor: "#ffffff", borderColor: null, borderWidth: 0, borderRadius: 16, padding: 24, backgroundImageUrl: null, backgroundImageFit: "cover", width: "fill", height: "auto" },
191
+ title: { visible: true, color: "#111827", fontSize: 18, fontWeight: "bold" },
192
+ body: { visible: true, color: "#374151", fontSize: 14, fontWeight: "normal" },
193
+ button: { visible: true, backgroundColor: "#2563eb", textColor: "#ffffff", borderRadius: 8, position: { top: null, bottom: 16, left: null, right: 16 } }
194
+ },
187
195
  message: {
188
196
  container: { backgroundColor: "#1f2937", borderColor: null, borderWidth: 0, borderRadius: 12, padding: 16, backgroundImageUrl: null, backgroundImageFit: "cover", width: "fill", height: "auto" },
189
197
  title: { visible: false, color: "#ffffff", fontSize: 16, fontWeight: "bold" },
@@ -281,6 +289,24 @@ function resolveExperienceStyle(type, custom) {
281
289
  }
282
290
 
283
291
  // src/firebase/api-client.ts
292
+ var MAX_RETRY_AFTER_MS = 15 * 6e4;
293
+ var ApiError = class extends Error {
294
+ constructor(message, status, retryAfterMs = null) {
295
+ super(message);
296
+ this.status = status;
297
+ this.retryAfterMs = retryAfterMs;
298
+ this.name = "ApiError";
299
+ }
300
+ status;
301
+ retryAfterMs;
302
+ };
303
+ function retryAfterMsFrom(response) {
304
+ const seconds = Number(response.headers?.get?.("retry-after"));
305
+ return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds * 1e3, MAX_RETRY_AFTER_MS) : null;
306
+ }
307
+ function failure(callName, response) {
308
+ return new ApiError(`${callName} failed with status ${response.status}`, response.status, response.status === 429 ? retryAfterMsFrom(response) : null);
309
+ }
284
310
  var REQUEST_TIMEOUT_MS = 15e3;
285
311
  async function fetchWithTimeout(url, init) {
286
312
  const controller = new AbortController();
@@ -331,7 +357,7 @@ var ApiClient = class {
331
357
  headers: this.headers()
332
358
  });
333
359
  if (!response.ok) {
334
- throw new Error(`getProjectConfig failed with status ${response.status}`);
360
+ throw failure("getProjectConfig", response);
335
361
  }
336
362
  return await response.json();
337
363
  }
@@ -342,7 +368,7 @@ var ApiClient = class {
342
368
  body: JSON.stringify(payload)
343
369
  });
344
370
  if (!response.ok) {
345
- throw new Error(`ingestLocationEvent failed with status ${response.status}`);
371
+ throw failure("ingestLocationEvent", response);
346
372
  }
347
373
  return await response.json();
348
374
  }
@@ -354,10 +380,10 @@ var ApiClient = class {
354
380
  headers: this.headers()
355
381
  });
356
382
  if (!response.ok) {
357
- throw new Error(`getPendingExperiences failed with status ${response.status}`);
383
+ throw failure("getPendingExperiences", response);
358
384
  }
359
385
  const body = await response.json();
360
- return body.experiences;
386
+ return { experiences: body.experiences ?? [], revoked: body.revoked ?? [] };
361
387
  }
362
388
  /** Confirms a delivery was surfaced via `experience.received`, so it stops appearing in future polls. */
363
389
  async acknowledgeExperienceDelivery(deliveryId) {
@@ -367,7 +393,7 @@ var ApiClient = class {
367
393
  body: JSON.stringify({ deliveryId })
368
394
  });
369
395
  if (!response.ok) {
370
- throw new Error(`acknowledgeExperienceDelivery failed with status ${response.status}`);
396
+ throw failure("acknowledgeExperienceDelivery", response);
371
397
  }
372
398
  }
373
399
  };
@@ -396,27 +422,52 @@ function createEventPayload(params) {
396
422
  }
397
423
 
398
424
  // src/geofencing/state-machine.ts
425
+ var DEFAULT_EXIT_BUFFER_METERS = 20;
426
+ var DEFAULT_MAX_ACCURACY_METERS = 100;
427
+ var DEFAULT_TRANSITION_COOLDOWN_MS = 2 * 6e4;
399
428
  var GeofenceStateMachine = class {
400
429
  membershipByGeofenceId = /* @__PURE__ */ new Map();
430
+ lastTransitionAtByGeofenceId = /* @__PURE__ */ new Map();
431
+ exitBufferMeters;
432
+ maxAccuracyMeters;
433
+ cooldownMs;
434
+ now;
435
+ constructor(options = {}) {
436
+ this.exitBufferMeters = options.exitBufferMeters ?? DEFAULT_EXIT_BUFFER_METERS;
437
+ this.maxAccuracyMeters = options.maxAccuracyMeters ?? DEFAULT_MAX_ACCURACY_METERS;
438
+ this.cooldownMs = options.cooldownMs ?? DEFAULT_TRANSITION_COOLDOWN_MS;
439
+ this.now = options.now ?? Date.now;
440
+ }
401
441
  evaluate(location, geofences) {
442
+ if (location.accuracy !== void 0 && location.accuracy > this.maxAccuracyMeters) {
443
+ return [];
444
+ }
402
445
  const transitions = [];
403
446
  const activeIds = /* @__PURE__ */ new Set();
447
+ const exitBuffer = Math.max(this.exitBufferMeters, location.accuracy ?? 0);
448
+ const now = this.now();
404
449
  for (const geofence of geofences) {
405
450
  activeIds.add(geofence.id);
406
451
  const distance = calculateDistanceMeters(location, geofence.center);
407
- const nextMembership = distance <= geofence.radiusMeters ? "INSIDE" : "OUTSIDE";
408
452
  const previousMembership = this.membershipByGeofenceId.get(geofence.id) ?? "OUTSIDE";
409
- if (previousMembership !== nextMembership) {
410
- transitions.push({
411
- geofence,
412
- type: nextMembership === "INSIDE" ? "geofence.enter" : "geofence.exit"
413
- });
453
+ const nextMembership = previousMembership === "INSIDE" ? distance > geofence.radiusMeters + exitBuffer ? "OUTSIDE" : "INSIDE" : distance <= geofence.radiusMeters ? "INSIDE" : "OUTSIDE";
454
+ if (previousMembership === nextMembership) {
455
+ this.membershipByGeofenceId.set(geofence.id, nextMembership);
456
+ continue;
457
+ }
458
+ const lastTransitionAt = this.lastTransitionAtByGeofenceId.get(geofence.id);
459
+ if (lastTransitionAt !== void 0 && now - lastTransitionAt < this.cooldownMs) {
460
+ this.membershipByGeofenceId.set(geofence.id, previousMembership);
461
+ continue;
414
462
  }
415
463
  this.membershipByGeofenceId.set(geofence.id, nextMembership);
464
+ this.lastTransitionAtByGeofenceId.set(geofence.id, now);
465
+ transitions.push({ geofence, type: nextMembership === "INSIDE" ? "geofence.enter" : "geofence.exit" });
416
466
  }
417
- for (const id of this.membershipByGeofenceId.keys()) {
467
+ for (const id of [...this.membershipByGeofenceId.keys()]) {
418
468
  if (!activeIds.has(id)) {
419
469
  this.membershipByGeofenceId.delete(id);
470
+ this.lastTransitionAtByGeofenceId.delete(id);
420
471
  }
421
472
  }
422
473
  return transitions;
@@ -424,8 +475,62 @@ var GeofenceStateMachine = class {
424
475
  getMembership(geofenceId) {
425
476
  return this.membershipByGeofenceId.get(geofenceId) ?? "OUTSIDE";
426
477
  }
478
+ isInsideAny() {
479
+ for (const membership of this.membershipByGeofenceId.values()) {
480
+ if (membership === "INSIDE") return true;
481
+ }
482
+ return false;
483
+ }
484
+ exportState() {
485
+ return {
486
+ membership: Object.fromEntries(this.membershipByGeofenceId),
487
+ lastTransitionAt: Object.fromEntries(this.lastTransitionAtByGeofenceId)
488
+ };
489
+ }
490
+ /** Only for a machine that has not evaluated anything yet. */
491
+ restoreState(state) {
492
+ this.membershipByGeofenceId.clear();
493
+ this.lastTransitionAtByGeofenceId.clear();
494
+ for (const [id, membership] of Object.entries(state.membership)) {
495
+ if (membership === "INSIDE" || membership === "OUTSIDE") this.membershipByGeofenceId.set(id, membership);
496
+ }
497
+ for (const [id, at] of Object.entries(state.lastTransitionAt)) {
498
+ if (typeof at === "number" && Number.isFinite(at)) this.lastTransitionAtByGeofenceId.set(id, at);
499
+ }
500
+ }
427
501
  reset() {
428
502
  this.membershipByGeofenceId.clear();
503
+ this.lastTransitionAtByGeofenceId.clear();
504
+ }
505
+ };
506
+
507
+ // src/geofencing/state-store.ts
508
+ var STORAGE_KEY = "@geopulse/geofence-state";
509
+ var GEOFENCE_STATE_MAX_AGE_MS = 12 * 60 * 60 * 1e3;
510
+ var GeofenceStateStore = class {
511
+ constructor(store, now = Date.now) {
512
+ this.store = store;
513
+ this.now = now;
514
+ }
515
+ store;
516
+ now;
517
+ async load() {
518
+ try {
519
+ const raw = await this.store.getItem(STORAGE_KEY);
520
+ if (!raw) return null;
521
+ const saved = JSON.parse(raw);
522
+ if (typeof saved.savedAt !== "number" || this.now() - saved.savedAt > GEOFENCE_STATE_MAX_AGE_MS) return null;
523
+ if (typeof saved.membership !== "object" || saved.membership === null) return null;
524
+ return { membership: saved.membership, lastTransitionAt: saved.lastTransitionAt ?? {} };
525
+ } catch {
526
+ return null;
527
+ }
528
+ }
529
+ async save(state) {
530
+ try {
531
+ await this.store.setItem(STORAGE_KEY, JSON.stringify({ ...state, savedAt: this.now() }));
532
+ } catch {
533
+ }
429
534
  }
430
535
  };
431
536
 
@@ -492,65 +597,98 @@ var InMemoryEventQueueStore = class {
492
597
  };
493
598
 
494
599
  // src/storage/queue.ts
600
+ var MAX_QUEUE_SIZE = 100;
601
+ var MAX_QUEUED_EVENT_AGE_MS = 24 * 60 * 60 * 1e3;
495
602
  var PendingEventQueue = class {
496
603
  constructor(store = new InMemoryEventQueueStore()) {
497
604
  this.store = store;
498
605
  }
499
606
  store;
500
- async enqueue(payload) {
501
- await this.store.save({
502
- eventId: payload.id,
503
- type: payload.type,
504
- applicationId: payload.applicationId,
505
- deviceId: payload.deviceId,
506
- locationId: payload.locationId,
507
- geofenceId: payload.geofenceId,
508
- timestamp: payload.timestamp,
509
- payload,
510
- attempts: 0,
511
- status: "pending",
512
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
513
- lastAttemptAt: null,
514
- nextAttemptAt: null
607
+ chain = Promise.resolve();
608
+ serialized(operation) {
609
+ const result = this.chain.then(operation, operation);
610
+ this.chain = result.catch(() => void 0);
611
+ return result;
612
+ }
613
+ enqueue(payload) {
614
+ return this.serialized(async () => {
615
+ await this.store.save({
616
+ eventId: payload.id,
617
+ type: payload.type,
618
+ applicationId: payload.applicationId,
619
+ deviceId: payload.deviceId,
620
+ locationId: payload.locationId,
621
+ geofenceId: payload.geofenceId,
622
+ timestamp: payload.timestamp,
623
+ payload,
624
+ attempts: 0,
625
+ status: "pending",
626
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
627
+ lastAttemptAt: null,
628
+ nextAttemptAt: null
629
+ });
630
+ await this.trim();
515
631
  });
516
632
  }
517
633
  /** Every queued event, regardless of retry timing — used for diagnostics/pending counts. */
518
- async listPending() {
519
- return this.store.getAll();
520
- }
521
- /** Only events whose backoff window has elapsed (or that have never been attempted) — used by SyncManager.retryPending(). */
522
- async listReadyForRetry(now = /* @__PURE__ */ new Date()) {
523
- const all = await this.store.getAll();
524
- return all.filter(
525
- (event) => event.status !== "sending" && (!event.nextAttemptAt || new Date(event.nextAttemptAt) <= now)
526
- );
634
+ listPending() {
635
+ return this.serialized(() => this.store.getAll());
636
+ }
637
+ /** Only events whose backoff window has elapsed (or that have never been attempted) — used by SyncManager.retryPending(). Drops events too old to ever be accepted. */
638
+ listReadyForRetry(now = /* @__PURE__ */ new Date()) {
639
+ return this.serialized(async () => {
640
+ let all = await this.store.getAll();
641
+ for (const event of all.filter((candidate) => isTooOld(candidate, now.getTime()))) {
642
+ await this.store.remove(event.eventId);
643
+ }
644
+ all = all.filter((event) => !isTooOld(event, now.getTime()));
645
+ return all.filter((event) => event.status !== "sending" && (!event.nextAttemptAt || new Date(event.nextAttemptAt) <= now));
646
+ });
527
647
  }
528
648
  /** Marks an event as in-flight so a concurrent retry pass won't also pick it up. */
529
- async markSending(eventId) {
530
- const existing = await this.get(eventId);
531
- if (!existing) return;
532
- await this.store.save({ ...existing, status: "sending" });
533
- }
534
- async markAttemptFailed(eventId) {
535
- const existing = await this.get(eventId);
536
- if (!existing) return;
537
- const attempts = existing.attempts + 1;
538
- const now = /* @__PURE__ */ new Date();
539
- await this.store.save({
540
- ...existing,
541
- status: "failed",
542
- attempts,
543
- lastAttemptAt: now.toISOString(),
544
- nextAttemptAt: new Date(now.getTime() + computeRetryDelayMs(attempts)).toISOString()
649
+ markSending(eventId) {
650
+ return this.serialized(async () => {
651
+ const existing = await this.get(eventId);
652
+ if (!existing) return;
653
+ await this.store.save({ ...existing, status: "sending" });
545
654
  });
546
655
  }
547
- async markSent(eventId) {
548
- await this.store.remove(eventId);
656
+ /** `minDelayMs` lets a 429's Retry-After push the next attempt later than the normal backoff. */
657
+ markAttemptFailed(eventId, minDelayMs = 0) {
658
+ return this.serialized(async () => {
659
+ const existing = await this.get(eventId);
660
+ if (!existing) return;
661
+ const attempts = existing.attempts + 1;
662
+ const now = /* @__PURE__ */ new Date();
663
+ await this.store.save({
664
+ ...existing,
665
+ status: "failed",
666
+ attempts,
667
+ lastAttemptAt: now.toISOString(),
668
+ nextAttemptAt: new Date(now.getTime() + Math.max(computeRetryDelayMs(attempts), minDelayMs)).toISOString()
669
+ });
670
+ });
671
+ }
672
+ markSent(eventId) {
673
+ return this.serialized(() => this.store.remove(eventId));
549
674
  }
550
675
  async get(eventId) {
551
676
  return (await this.store.getAll()).find((event) => event.eventId === eventId);
552
677
  }
678
+ /** Keeps the queue within MAX_QUEUE_SIZE by dropping the oldest events that are not being sent right now. */
679
+ async trim() {
680
+ const all = await this.store.getAll();
681
+ if (all.length <= MAX_QUEUE_SIZE) return;
682
+ const droppable = all.filter((event) => event.status !== "sending").sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
683
+ for (const event of droppable.slice(0, all.length - MAX_QUEUE_SIZE)) {
684
+ await this.store.remove(event.eventId);
685
+ }
686
+ }
553
687
  };
688
+ function isTooOld(event, nowMs) {
689
+ const eventTime = Date.parse(event.timestamp);
690
+ return !Number.isNaN(eventTime) && nowMs - eventTime > MAX_QUEUED_EVENT_AGE_MS;
691
+ }
554
692
 
555
693
  // src/sync/network-monitor.ts
556
694
  var ManualNetworkMonitor = class {
@@ -572,20 +710,29 @@ var ManualNetworkMonitor = class {
572
710
 
573
711
  // src/sync/sync-manager.ts
574
712
  var DEFAULT_RETRY_INTERVAL_MS = 3e4;
713
+ var MAX_UPLOADS_PER_MINUTE = 3;
714
+ var FORCED_RETRY_MIN_GAP_MS = 1e4;
715
+ var FORBIDDEN_PAUSE_MS = MAX_RETRY_AFTER_MS;
716
+ var DEFAULT_RATE_LIMIT_PAUSE_MS = 6e4;
575
717
  var SyncManager = class {
576
- constructor(apiClient, queue, retryIntervalMs = DEFAULT_RETRY_INTERVAL_MS, logger) {
718
+ constructor(apiClient, queue, retryIntervalMs = DEFAULT_RETRY_INTERVAL_MS, logger, onDelivered) {
577
719
  this.apiClient = apiClient;
578
720
  this.queue = queue;
579
721
  this.retryIntervalMs = retryIntervalMs;
580
722
  this.logger = logger;
723
+ this.onDelivered = onDelivered;
581
724
  }
582
725
  apiClient;
583
726
  queue;
584
727
  retryIntervalMs;
585
728
  logger;
729
+ onDelivered;
586
730
  retryTimer = null;
587
731
  lastSyncAt = null;
588
732
  lastUploadResult = null;
733
+ uploadTimes = [];
734
+ pausedUntil = 0;
735
+ lastForcedRetryAt = 0;
589
736
  /**
590
737
  * Write-ahead: the event is durably queued *before* any network attempt is
591
738
  * made, so an app kill mid-request can never lose it (spec: "true
@@ -597,8 +744,18 @@ var SyncManager = class {
597
744
  */
598
745
  async sendOrQueue(payload) {
599
746
  await this.queue.enqueue(payload);
747
+ if (!this.canUploadNow()) {
748
+ this.logger?.log("Upload deferred (rate cap or server asked to wait)", { eventId: payload.id });
749
+ return;
750
+ }
600
751
  await this.attemptDelivery(payload.id, payload);
601
752
  }
753
+ canUploadNow() {
754
+ const now = Date.now();
755
+ if (now < this.pausedUntil) return false;
756
+ this.uploadTimes = this.uploadTimes.filter((at) => now - at < 6e4);
757
+ return this.uploadTimes.length < MAX_UPLOADS_PER_MINUTE;
758
+ }
602
759
  /**
603
760
  * Attempts queued events. By default only ones whose backoff window has
604
761
  * elapsed (the timer-driven path, `startAutoRetry` — the whole point of
@@ -611,9 +768,17 @@ var SyncManager = class {
611
768
  * once it's actually back online.
612
769
  */
613
770
  async retryPending(options = {}) {
614
- const ready = options.force ? (await this.queue.listPending()).filter((event) => event.status !== "sending") : await this.queue.listReadyForRetry();
615
- const result = { attempted: ready.length, delivered: 0, failed: 0 };
771
+ let force = options.force === true;
772
+ if (force) {
773
+ const now = Date.now();
774
+ if (now - this.lastForcedRetryAt < FORCED_RETRY_MIN_GAP_MS) force = false;
775
+ else this.lastForcedRetryAt = now;
776
+ }
777
+ const ready = force ? (await this.queue.listPending()).filter((event) => event.status !== "sending") : await this.queue.listReadyForRetry();
778
+ const result = { attempted: 0, delivered: 0, failed: 0 };
616
779
  for (const event of ready) {
780
+ if (!this.canUploadNow()) break;
781
+ result.attempted++;
617
782
  const attemptNumber = event.attempts + 1;
618
783
  const delivered = await this.attemptDelivery(event.eventId, { ...event.payload, attempts: attemptNumber });
619
784
  if (delivered) {
@@ -634,21 +799,39 @@ var SyncManager = class {
634
799
  * was delivered, purely so `retryPending` can tally its `SyncResult`.
635
800
  */
636
801
  async attemptDelivery(eventId, payload) {
802
+ this.uploadTimes.push(Date.now());
637
803
  await this.queue.markSending(eventId);
638
804
  this.logger?.log("Event upload started", { eventId, attempt: payload.attempts });
805
+ let result;
639
806
  try {
640
- await this.apiClient.ingestEvent(payload);
807
+ result = await this.apiClient.ingestEvent(payload);
641
808
  await this.queue.markSent(eventId);
642
809
  this.lastSyncAt = (/* @__PURE__ */ new Date()).toISOString();
643
810
  this.lastUploadResult = "success";
644
811
  this.logger?.log("Event upload succeeded", { eventId });
645
- return true;
646
812
  } catch (error) {
647
- await this.queue.markAttemptFailed(eventId);
648
813
  this.lastUploadResult = "failure";
649
- this.logger?.warn("Event upload failed \u2014 retry scheduled", { eventId, error: String(error) });
814
+ if (error instanceof ApiError && error.status === 429) {
815
+ const waitMs = error.retryAfterMs ?? DEFAULT_RATE_LIMIT_PAUSE_MS;
816
+ this.pausedUntil = Date.now() + waitMs;
817
+ await this.queue.markAttemptFailed(eventId, waitMs);
818
+ this.logger?.warn("Server rate limit \u2014 pausing uploads", { eventId, waitMs });
819
+ } else if (error instanceof ApiError && isPermanentRejection(error.status)) {
820
+ await this.queue.markSent(eventId);
821
+ if (error.status === 403) this.pausedUntil = Date.now() + FORBIDDEN_PAUSE_MS;
822
+ this.logger?.warn("Event rejected permanently \u2014 dropped", { eventId, status: error.status });
823
+ } else {
824
+ await this.queue.markAttemptFailed(eventId);
825
+ this.logger?.warn("Event upload failed \u2014 retry scheduled", { eventId, error: String(error) });
826
+ }
650
827
  return false;
651
828
  }
829
+ try {
830
+ this.onDelivered?.(result);
831
+ } catch (error) {
832
+ this.logger?.warn("onDelivered handler threw", { eventId, error: String(error) });
833
+ }
834
+ return true;
652
835
  }
653
836
  startAutoRetry() {
654
837
  if (this.retryTimer) return;
@@ -669,6 +852,9 @@ var SyncManager = class {
669
852
  return this.lastUploadResult;
670
853
  }
671
854
  };
855
+ function isPermanentRejection(status) {
856
+ return status >= 400 && status < 500 && status !== 408 && status !== 429;
857
+ }
672
858
 
673
859
  // src/core/logger.ts
674
860
  var Logger = class {
@@ -704,7 +890,8 @@ var ApplicationMismatchError = class extends Error {
704
890
  this.name = "ApplicationMismatchError";
705
891
  }
706
892
  };
707
- var DEFAULT_EXPERIENCE_POLL_INTERVAL_MS = 3e4;
893
+ var DEFAULT_EXPERIENCE_POLL_INTERVAL_MS = 15 * 6e4;
894
+ var CONFIG_REFRESH_INTERVAL_MS = 60 * 6e4;
708
895
  var MIN_TIMER_INTERVAL_MS = 1e3;
709
896
  function clampIntervalMs(value) {
710
897
  if (value === void 0) {
@@ -731,12 +918,25 @@ var GeoPulseSdk = class {
731
918
  deviceId = null;
732
919
  locationError = null;
733
920
  unsubscribeNetwork = null;
734
- stateMachine = new GeofenceStateMachine();
921
+ stateMachine;
922
+ geofenceStateStore;
923
+ geofenceStateRestored = false;
924
+ realtime = null;
925
+ createRealtime;
926
+ lastConfigFetchAt = 0;
927
+ configRefreshInFlight = false;
735
928
  listeners = /* @__PURE__ */ new Map();
736
929
  experienceListeners = /* @__PURE__ */ new Set();
737
930
  /** Client-side duplicate protection (spec section 14) — belt-and-suspenders alongside the server-side ack; a delivery already acked but re-observed before the ack lands must not fire `experience.received` twice. */
738
- seenExperienceDeliveryIds = /* @__PURE__ */ new Set();
931
+ seenExperienceRevisions = /* @__PURE__ */ new Map();
932
+ experienceRemovedListeners = /* @__PURE__ */ new Set();
933
+ removedDeliveryIds = /* @__PURE__ */ new Set();
739
934
  experiencePollTimer = null;
935
+ experiencePollInFlight = false;
936
+ /** Set while a poll that was refused with "too many requests" waits to be tried again. */
937
+ pollRetryTimer = null;
938
+ /** Set when a poll was asked for while one was already running; that one may have queried before the new delivery existed. */
939
+ experiencePollRequested = false;
740
940
  experiencesReceivedCount = 0;
741
941
  logger;
742
942
  locationProvider;
@@ -758,6 +958,9 @@ var GeoPulseSdk = class {
758
958
  this.networkMonitor = dependencies.networkMonitor ?? new ManualNetworkMonitor();
759
959
  this.getPlatform = dependencies.getPlatform ?? (() => "unknown");
760
960
  this.logger = new Logger(dependencies.debug ?? false);
961
+ this.stateMachine = new GeofenceStateMachine(dependencies.stateMachineOptions);
962
+ this.geofenceStateStore = new GeofenceStateStore(dependencies.stateStore ?? new InMemoryKeyValueStore());
963
+ this.createRealtime = dependencies.createRealtimeClient;
761
964
  }
762
965
  /**
763
966
  * Resolves this device's persistent id, then fetches (or falls back to
@@ -783,8 +986,25 @@ var GeoPulseSdk = class {
783
986
  this.config = config;
784
987
  this.experiencePollIntervalMs = clampIntervalMs(config.experiencePollIntervalMs) ?? DEFAULT_EXPERIENCE_POLL_INTERVAL_MS;
785
988
  this.apiClient = this.buildApiClient(config);
786
- this.syncManager = new SyncManager(this.apiClient, this.queue, clampIntervalMs(config.retryIntervalMs), this.logger);
989
+ this.syncManager = new SyncManager(
990
+ this.apiClient,
991
+ this.queue,
992
+ clampIntervalMs(config.retryIntervalMs),
993
+ this.logger,
994
+ (result) => this.handleEventDelivered(result)
995
+ );
787
996
  this.deviceId = await getOrCreateDeviceId(this.deviceIdStore);
997
+ this.realtime?.disconnect();
998
+ this.realtime = this.createRealtime ? this.createRealtime({
999
+ baseUrl: config.functionsBaseUrl || DEFAULT_BACKEND_URL,
1000
+ sdkKey: config.apiKey,
1001
+ applicationId: config.applicationId,
1002
+ deviceId: this.deviceId,
1003
+ onChanged: () => void this.pollForExperiences(),
1004
+ // Whatever changed while the socket was down is picked up now.
1005
+ onConnected: () => void this.pollForExperiences(),
1006
+ logger: this.logger
1007
+ }) : null;
788
1008
  try {
789
1009
  const projectConfig = await this.apiClient.getProjectConfig({
790
1010
  deviceId: this.deviceId,
@@ -796,6 +1016,7 @@ var GeoPulseSdk = class {
796
1016
  }
797
1017
  this.applyProjectConfig(projectConfig);
798
1018
  this.configurationStatus = "synced";
1019
+ this.lastConfigFetchAt = Date.now();
799
1020
  await this.configCache.save(projectConfig);
800
1021
  this.status = "initialized";
801
1022
  this.logger.log("Configuration synced", { geofenceCount: projectConfig.geofences.length, configVersion: projectConfig.configVersion });
@@ -834,6 +1055,11 @@ var GeoPulseSdk = class {
834
1055
  this.status = "error";
835
1056
  throw new Error(`Location permission not granted (status: ${permission})`);
836
1057
  }
1058
+ if (!this.geofenceStateRestored) {
1059
+ this.geofenceStateRestored = true;
1060
+ const saved = await this.geofenceStateStore.load();
1061
+ if (saved) this.stateMachine.restoreState(saved);
1062
+ }
837
1063
  this.locationError = null;
838
1064
  await this.locationProvider.start(
839
1065
  (location) => {
@@ -857,12 +1083,14 @@ var GeoPulseSdk = class {
857
1083
  this.syncManager?.startAutoRetry();
858
1084
  this.startExperiencePolling();
859
1085
  this.status = "running";
1086
+ this.syncRealtime();
860
1087
  this.logger.log("GeoPulse.start()");
861
1088
  }
862
1089
  stop() {
863
1090
  this.locationProvider.stop();
864
1091
  this.syncManager?.stopAutoRetry();
865
1092
  this.stopExperiencePolling();
1093
+ this.realtime?.disconnect();
866
1094
  this.unsubscribeNetwork?.();
867
1095
  this.unsubscribeNetwork = null;
868
1096
  this.status = "stopped";
@@ -879,6 +1107,10 @@ var GeoPulseSdk = class {
879
1107
  this.experienceListeners.add(callback);
880
1108
  return;
881
1109
  }
1110
+ if (eventName === "experience.removed") {
1111
+ this.experienceRemovedListeners.add(callback);
1112
+ return;
1113
+ }
882
1114
  if (!this.listeners.has(eventName)) {
883
1115
  this.listeners.set(eventName, /* @__PURE__ */ new Set());
884
1116
  }
@@ -889,6 +1121,10 @@ var GeoPulseSdk = class {
889
1121
  this.experienceListeners.delete(callback);
890
1122
  return;
891
1123
  }
1124
+ if (eventName === "experience.removed") {
1125
+ this.experienceRemovedListeners.delete(callback);
1126
+ return;
1127
+ }
892
1128
  this.listeners.get(eventName)?.delete(callback);
893
1129
  }
894
1130
  // --- Small read-only accessors beyond the core 7-method API, so a host
@@ -970,28 +1206,64 @@ var GeoPulseSdk = class {
970
1206
  void this.pollForExperiences();
971
1207
  this.experiencePollTimer = setInterval(() => {
972
1208
  void this.pollForExperiences();
1209
+ void this.refreshConfigIfStale();
973
1210
  }, this.experiencePollIntervalMs);
974
1211
  }
975
1212
  stopExperiencePolling() {
1213
+ if (this.pollRetryTimer) {
1214
+ clearTimeout(this.pollRetryTimer);
1215
+ this.pollRetryTimer = null;
1216
+ }
976
1217
  if (this.experiencePollTimer) {
977
1218
  clearInterval(this.experiencePollTimer);
978
1219
  this.experiencePollTimer = null;
979
1220
  }
980
1221
  }
1222
+ /**
1223
+ * The backend says in its ingest response whether the event created an experience for this
1224
+ * device. Poll right away if so (or if it doesn't say — an older backend), skip if it says
1225
+ * there is nothing. Does nothing unless polling is active, so a stopped SDK never polls.
1226
+ */
1227
+ handleEventDelivered(result) {
1228
+ if (this.experiencePollTimer === null || result?.experiencesPending === false) {
1229
+ return;
1230
+ }
1231
+ void this.pollForExperiences();
1232
+ }
1233
+ /** One poll at a time: a second request while one is running makes it run once more afterwards, rather than overlapping. */
981
1234
  async pollForExperiences() {
1235
+ if (this.experiencePollInFlight) {
1236
+ this.experiencePollRequested = true;
1237
+ return;
1238
+ }
1239
+ this.experiencePollInFlight = true;
1240
+ try {
1241
+ do {
1242
+ this.experiencePollRequested = false;
1243
+ await this.pollForExperiencesOnce();
1244
+ } while (this.experiencePollRequested);
1245
+ } finally {
1246
+ this.experiencePollInFlight = false;
1247
+ }
1248
+ }
1249
+ async pollForExperiencesOnce() {
982
1250
  if (!this.apiClient || !this.deviceId) {
983
1251
  return;
984
1252
  }
985
1253
  try {
986
- const experiences = await this.apiClient.getPendingExperiences(this.deviceId);
1254
+ const { experiences, revoked } = await this.apiClient.getPendingExperiences(this.deviceId);
987
1255
  for (const experience of experiences) {
988
- if (this.seenExperienceDeliveryIds.has(experience.deliveryId)) {
1256
+ const revision = experience.revision ?? 0;
1257
+ const seenRevision = this.seenExperienceRevisions.get(experience.deliveryId);
1258
+ if (seenRevision === revision) {
989
1259
  continue;
990
1260
  }
991
- this.seenExperienceDeliveryIds.add(experience.deliveryId);
1261
+ const isUpdate = seenRevision !== void 0;
1262
+ this.seenExperienceRevisions.set(experience.deliveryId, revision);
1263
+ this.removedDeliveryIds.delete(experience.deliveryId);
992
1264
  this.experiencesReceivedCount += 1;
993
- this.logger.log("Experience received", { deliveryId: experience.deliveryId, campaignId: experience.campaignId });
994
- this.experienceListeners.forEach((listener) => listener(experience));
1265
+ this.logger.log("Experience received", { deliveryId: experience.deliveryId, campaignId: experience.campaignId, isUpdate });
1266
+ this.experienceListeners.forEach((listener) => listener({ ...experience, isUpdate }));
995
1267
  await this.apiClient.acknowledgeExperienceDelivery(experience.deliveryId).catch((error) => {
996
1268
  this.logger.warn("Failed to acknowledge experience delivery", {
997
1269
  deliveryId: experience.deliveryId,
@@ -999,10 +1271,84 @@ var GeoPulseSdk = class {
999
1271
  });
1000
1272
  });
1001
1273
  }
1274
+ for (const removed of revoked) {
1275
+ if (!this.removedDeliveryIds.has(removed.deliveryId)) {
1276
+ this.removedDeliveryIds.add(removed.deliveryId);
1277
+ this.seenExperienceRevisions.delete(removed.deliveryId);
1278
+ this.logger.log("Experience removed", { deliveryId: removed.deliveryId, campaignId: removed.campaignId });
1279
+ this.experienceRemovedListeners.forEach((listener) => listener(removed));
1280
+ }
1281
+ await this.apiClient.acknowledgeExperienceDelivery(removed.deliveryId).catch((error) => {
1282
+ this.logger.warn("Failed to acknowledge experience removal", {
1283
+ deliveryId: removed.deliveryId,
1284
+ error: error instanceof Error ? error.message : String(error)
1285
+ });
1286
+ });
1287
+ }
1002
1288
  } catch (error) {
1003
1289
  this.logger.warn("Polling for experiences failed", { error: error instanceof Error ? error.message : String(error) });
1290
+ if (error instanceof ApiError && error.status === 429) {
1291
+ this.schedulePollRetry(error.retryAfterMs ?? 3e4);
1292
+ }
1293
+ }
1294
+ }
1295
+ schedulePollRetry(waitMs) {
1296
+ if (this.pollRetryTimer !== null || this.experiencePollTimer === null) {
1297
+ return;
1298
+ }
1299
+ this.pollRetryTimer = setTimeout(() => {
1300
+ this.pollRetryTimer = null;
1301
+ void this.pollForExperiences();
1302
+ }, waitMs + Math.random() * 5e3);
1303
+ }
1304
+ /**
1305
+ * Re-reads the geofence list when it is an hour old, so a geofence an admin added, moved, resized or removed reaches a phone
1306
+ * that keeps the app open (otherwise only a restart picks it up). Runs from the poll timer, so it costs no timer of its own.
1307
+ * A failure keeps the list we have. A disabled application stops the SDK.
1308
+ */
1309
+ async refreshConfigIfStale() {
1310
+ if (!this.apiClient || !this.deviceId || !this.config || this.configRefreshInFlight) {
1311
+ return;
1312
+ }
1313
+ if (Date.now() - this.lastConfigFetchAt < CONFIG_REFRESH_INTERVAL_MS) {
1314
+ return;
1315
+ }
1316
+ this.configRefreshInFlight = true;
1317
+ try {
1318
+ const projectConfig = await this.apiClient.getProjectConfig({ deviceId: this.deviceId, platform: this.getPlatform(), sdkVersion: SDK_VERSION });
1319
+ if (projectConfig.applicationId !== this.config.applicationId) {
1320
+ return;
1321
+ }
1322
+ this.lastConfigFetchAt = Date.now();
1323
+ this.applyProjectConfig(projectConfig);
1324
+ this.configurationStatus = "synced";
1325
+ await this.configCache.save(projectConfig);
1326
+ this.logger.log("Configuration refreshed", { geofenceCount: projectConfig.geofences.length, configVersion: projectConfig.configVersion });
1327
+ if (!projectConfig.applicationEnabled) {
1328
+ this.logger.warn("Application disabled \u2014 stopping");
1329
+ this.stop();
1330
+ this.status = "error";
1331
+ }
1332
+ } catch (error) {
1333
+ this.logger.warn("Configuration refresh failed \u2014 keeping the current geofences", { error: error instanceof Error ? error.message : String(error) });
1334
+ } finally {
1335
+ this.configRefreshInFlight = false;
1004
1336
  }
1005
1337
  }
1338
+ /** Keeps the push connection open exactly while the user is inside a geofence. */
1339
+ syncRealtime() {
1340
+ if (!this.realtime || this.status === "stopped") {
1341
+ return;
1342
+ }
1343
+ if (this.stateMachine.isInsideAny()) {
1344
+ this.realtime.connect();
1345
+ } else {
1346
+ this.realtime.disconnect();
1347
+ }
1348
+ }
1349
+ async persistGeofenceState() {
1350
+ await this.geofenceStateStore.save(this.stateMachine.exportState());
1351
+ }
1006
1352
  applyProjectConfig(projectConfig) {
1007
1353
  this.resolvedProjectId = projectConfig.projectId;
1008
1354
  this.resolvedApplicationId = projectConfig.applicationId;
@@ -1016,6 +1362,10 @@ var GeoPulseSdk = class {
1016
1362
  return;
1017
1363
  }
1018
1364
  const transitions = this.stateMachine.evaluate(location, this.geofences);
1365
+ if (transitions.length > 0) {
1366
+ void this.persistGeofenceState();
1367
+ }
1368
+ this.syncRealtime();
1019
1369
  for (const transition of transitions) {
1020
1370
  this.logger.log("Geofence state changed", { geofenceId: transition.geofence.id, type: transition.type });
1021
1371
  const event = createEventPayload({
@@ -1054,7 +1404,8 @@ var DeviceLocationProvider = class {
1054
1404
  onUpdate({
1055
1405
  latitude: position.coords.latitude,
1056
1406
  longitude: position.coords.longitude,
1057
- timestamp: new Date(position.timestamp).toISOString()
1407
+ timestamp: new Date(position.timestamp).toISOString(),
1408
+ ...typeof position.coords.accuracy === "number" ? { accuracy: position.coords.accuracy } : {}
1058
1409
  });
1059
1410
  }
1060
1411
  );
@@ -1093,10 +1444,10 @@ var ExpoPermissionProvider = class {
1093
1444
 
1094
1445
  // src/storage/async-storage-store.ts
1095
1446
  var import_async_storage2 = __toESM(require("@react-native-async-storage/async-storage"));
1096
- var STORAGE_KEY = "@geopulse/pending-events";
1447
+ var STORAGE_KEY2 = "@geopulse/pending-events";
1097
1448
  var AsyncStorageEventQueueStore = class {
1098
1449
  async getAll() {
1099
- const raw = await import_async_storage2.default.getItem(STORAGE_KEY);
1450
+ const raw = await import_async_storage2.default.getItem(STORAGE_KEY2);
1100
1451
  if (!raw) return [];
1101
1452
  try {
1102
1453
  return JSON.parse(raw);
@@ -1107,14 +1458,112 @@ var AsyncStorageEventQueueStore = class {
1107
1458
  async save(event) {
1108
1459
  const all = await this.getAll();
1109
1460
  const next = [...all.filter((existing) => existing.eventId !== event.eventId), event];
1110
- await import_async_storage2.default.setItem(STORAGE_KEY, JSON.stringify(next));
1461
+ await import_async_storage2.default.setItem(STORAGE_KEY2, JSON.stringify(next));
1111
1462
  }
1112
1463
  async remove(eventId) {
1113
1464
  const all = await this.getAll();
1114
- await import_async_storage2.default.setItem(STORAGE_KEY, JSON.stringify(all.filter((existing) => existing.eventId !== eventId)));
1465
+ await import_async_storage2.default.setItem(STORAGE_KEY2, JSON.stringify(all.filter((existing) => existing.eventId !== eventId)));
1115
1466
  }
1116
1467
  };
1117
1468
 
1469
+ // src/realtime/realtime-client.ts
1470
+ var RECONNECT_BASE_MS = 5e3;
1471
+ var RECONNECT_MAX_MS = 5 * 6e4;
1472
+ var NO_RETRY_CODES = /* @__PURE__ */ new Set([4400, 4401, 4403]);
1473
+ function toRealtimeUrl(baseUrl) {
1474
+ return `${baseUrl.replace(/\/+$/, "").replace(/^http/, "ws")}/realtime`;
1475
+ }
1476
+ var RealtimeClient = class {
1477
+ constructor(options) {
1478
+ this.options = options;
1479
+ }
1480
+ options;
1481
+ socket = null;
1482
+ connected = false;
1483
+ wanted = false;
1484
+ failures = 0;
1485
+ reconnectTimer = null;
1486
+ isConnected() {
1487
+ return this.connected;
1488
+ }
1489
+ connect() {
1490
+ this.wanted = true;
1491
+ if (this.socket || this.reconnectTimer) return;
1492
+ this.open();
1493
+ }
1494
+ disconnect() {
1495
+ this.wanted = false;
1496
+ this.failures = 0;
1497
+ if (this.reconnectTimer) {
1498
+ clearTimeout(this.reconnectTimer);
1499
+ this.reconnectTimer = null;
1500
+ }
1501
+ const socket = this.socket;
1502
+ this.socket = null;
1503
+ this.connected = false;
1504
+ if (socket) {
1505
+ socket.onclose = null;
1506
+ socket.onmessage = null;
1507
+ socket.onerror = null;
1508
+ try {
1509
+ socket.close(1e3, "done");
1510
+ } catch {
1511
+ }
1512
+ }
1513
+ }
1514
+ open() {
1515
+ const create = this.options.createSocket ?? defaultCreateSocket;
1516
+ let socket;
1517
+ try {
1518
+ socket = create(toRealtimeUrl(this.options.baseUrl));
1519
+ } catch (error) {
1520
+ this.options.logger?.warn("Realtime unavailable", { error: String(error) });
1521
+ return;
1522
+ }
1523
+ if (!socket) return;
1524
+ this.socket = socket;
1525
+ socket.onopen = () => {
1526
+ socket.send(JSON.stringify({ type: "hello", sdkKey: this.options.sdkKey, applicationId: this.options.applicationId, deviceId: this.options.deviceId }));
1527
+ };
1528
+ socket.onmessage = (event) => {
1529
+ let message = null;
1530
+ try {
1531
+ message = JSON.parse(String(event.data));
1532
+ } catch {
1533
+ return;
1534
+ }
1535
+ if (message?.type === "ready") {
1536
+ this.connected = true;
1537
+ this.failures = 0;
1538
+ this.options.onConnected();
1539
+ } else if (message?.type === "changed") {
1540
+ this.options.onChanged();
1541
+ }
1542
+ };
1543
+ socket.onerror = () => {
1544
+ };
1545
+ socket.onclose = (event) => {
1546
+ this.socket = null;
1547
+ this.connected = false;
1548
+ if (!this.wanted) return;
1549
+ if (NO_RETRY_CODES.has(event.code)) {
1550
+ this.options.logger?.warn("Realtime rejected by the server; not retrying", { code: event.code });
1551
+ return;
1552
+ }
1553
+ this.failures += 1;
1554
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** (this.failures - 1), RECONNECT_MAX_MS);
1555
+ this.reconnectTimer = setTimeout(() => {
1556
+ this.reconnectTimer = null;
1557
+ if (this.wanted) this.open();
1558
+ }, Math.round(delay * (0.8 + Math.random() * 0.4)));
1559
+ };
1560
+ }
1561
+ };
1562
+ function defaultCreateSocket(url) {
1563
+ const Ctor = globalThis.WebSocket;
1564
+ return Ctor ? new Ctor(url) : null;
1565
+ }
1566
+
1118
1567
  // src/sync/netinfo-network-monitor.ts
1119
1568
  var import_netinfo = __toESM(require("@react-native-community/netinfo"));
1120
1569
  function toStatus(isConnected) {
@@ -1215,6 +1664,13 @@ function BannerExperience({ content }) {
1215
1664
  const style = resolveExperienceStyle("banner", content.style);
1216
1665
  return /* @__PURE__ */ import_react.default.createElement(ExperienceContainer, { content, style }, /* @__PURE__ */ import_react.default.createElement(ExperienceText, { visible: style.title.visible, text: content.title, color: style.title.color, fontSize: style.title.fontSize, fontWeight: style.title.fontWeight }), /* @__PURE__ */ import_react.default.createElement(ExperienceText, { visible: style.body.visible, text: content.body, color: style.body.color, fontSize: style.body.fontSize, fontWeight: style.body.fontWeight }), /* @__PURE__ */ import_react.default.createElement(ExperienceButton, { style, ctaLabel: content.ctaLabel, ctaUrl: content.ctaUrl }));
1217
1666
  }
1667
+ function AlertExperience({ content }) {
1668
+ const style = resolveExperienceStyle("alert", content.style);
1669
+ return /* @__PURE__ */ import_react.default.createElement(ExperienceContainer, { content, style }, /* @__PURE__ */ import_react.default.createElement(ExperienceText, { visible: style.title.visible, text: content.title, color: style.title.color, fontSize: style.title.fontSize, fontWeight: style.title.fontWeight }), /* @__PURE__ */ import_react.default.createElement(ExperienceText, { visible: style.body.visible, text: content.body, color: style.body.color, fontSize: style.body.fontSize, fontWeight: style.body.fontWeight }), /* @__PURE__ */ import_react.default.createElement(ExperienceButton, { style, ctaLabel: content.ctaLabel, ctaUrl: content.ctaUrl }));
1670
+ }
1671
+ function isAlertExperience(payload) {
1672
+ return payload.experience.type === "alert";
1673
+ }
1218
1674
  function MessageExperience({ content }) {
1219
1675
  const style = resolveExperienceStyle("message", content.style);
1220
1676
  return /* @__PURE__ */ import_react.default.createElement(ExperienceContainer, { content, style }, /* @__PURE__ */ import_react.default.createElement(ExperienceText, { visible: style.title.visible, text: content.title, color: style.title.color, fontSize: style.title.fontSize, fontWeight: style.title.fontWeight }), /* @__PURE__ */ import_react.default.createElement(ExperienceText, { visible: style.body.visible, text: content.body, color: style.body.color, fontSize: style.body.fontSize, fontWeight: style.body.fontWeight }), /* @__PURE__ */ import_react.default.createElement(ExperienceButton, { style, ctaLabel: content.ctaLabel, ctaUrl: content.ctaUrl }));
@@ -1225,6 +1681,8 @@ function ExperienceView({ experience }) {
1225
1681
  return /* @__PURE__ */ import_react.default.createElement(BannerExperience, { content: experience });
1226
1682
  case "message":
1227
1683
  return /* @__PURE__ */ import_react.default.createElement(MessageExperience, { content: experience });
1684
+ case "alert":
1685
+ return /* @__PURE__ */ import_react.default.createElement(AlertExperience, { content: experience });
1228
1686
  case "card":
1229
1687
  default:
1230
1688
  return /* @__PURE__ */ import_react.default.createElement(CardExperience, { content: experience });
@@ -1252,6 +1710,11 @@ var sdk = new GeoPulseSdk({
1252
1710
  queue: new PendingEventQueue(new AsyncStorageEventQueueStore()),
1253
1711
  deviceIdStore: persistentKeyValueStore,
1254
1712
  configCacheStore: persistentKeyValueStore,
1713
+ // The simulator is for trying things out: no 2-minute cooldown, and no saved "inside" state surviving a reload
1714
+ // (otherwise "enter" would not fire again after Fast Refresh while the saved state says you are still inside).
1715
+ stateStore: useSimulatedProviders ? void 0 : persistentKeyValueStore,
1716
+ stateMachineOptions: useSimulatedProviders ? { cooldownMs: 0 } : void 0,
1717
+ createRealtimeClient: (options) => new RealtimeClient(options),
1255
1718
  networkMonitor: new NetInfoNetworkMonitor(),
1256
1719
  getPlatform,
1257
1720
  debug: isDev
@@ -1282,6 +1745,7 @@ var GeoPulse = {
1282
1745
  };
1283
1746
  // Annotate the CommonJS export names for ESM import in node:
1284
1747
  0 && (module.exports = {
1748
+ AlertExperience,
1285
1749
  ApplicationMismatchError,
1286
1750
  BannerExperience,
1287
1751
  CardExperience,
@@ -1294,6 +1758,7 @@ var GeoPulse = {
1294
1758
  MessageExperience,
1295
1759
  NetInfoNetworkMonitor,
1296
1760
  SimulatedLocationProvider,
1297
- SimulatedPermissionProvider
1761
+ SimulatedPermissionProvider,
1762
+ isAlertExperience
1298
1763
  });
1299
1764
  //# sourceMappingURL=index.js.map