@sharib/geopulse-react-native 0.3.4 → 0.4.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/README.md +110 -15
- package/dist/index.d.ts +144 -11
- package/dist/index.js +522 -76
- package/dist/index.js.map +1 -1
- package/package.json +20 -17
package/dist/index.js
CHANGED
|
@@ -60,7 +60,7 @@ var AsyncStorageKeyValueStore = class {
|
|
|
60
60
|
|
|
61
61
|
// src/config/types.ts
|
|
62
62
|
var DEFAULT_BACKEND_URL = "https://geofends.onrender.com";
|
|
63
|
-
var SDK_VERSION = "0.
|
|
63
|
+
var SDK_VERSION = "0.4.0";
|
|
64
64
|
|
|
65
65
|
// src/config/key-value-store.ts
|
|
66
66
|
var InMemoryKeyValueStore = class {
|
|
@@ -281,6 +281,24 @@ function resolveExperienceStyle(type, custom) {
|
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
// src/firebase/api-client.ts
|
|
284
|
+
var MAX_RETRY_AFTER_MS = 15 * 6e4;
|
|
285
|
+
var ApiError = class extends Error {
|
|
286
|
+
constructor(message, status, retryAfterMs = null) {
|
|
287
|
+
super(message);
|
|
288
|
+
this.status = status;
|
|
289
|
+
this.retryAfterMs = retryAfterMs;
|
|
290
|
+
this.name = "ApiError";
|
|
291
|
+
}
|
|
292
|
+
status;
|
|
293
|
+
retryAfterMs;
|
|
294
|
+
};
|
|
295
|
+
function retryAfterMsFrom(response) {
|
|
296
|
+
const seconds = Number(response.headers?.get?.("retry-after"));
|
|
297
|
+
return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds * 1e3, MAX_RETRY_AFTER_MS) : null;
|
|
298
|
+
}
|
|
299
|
+
function failure(callName, response) {
|
|
300
|
+
return new ApiError(`${callName} failed with status ${response.status}`, response.status, response.status === 429 ? retryAfterMsFrom(response) : null);
|
|
301
|
+
}
|
|
284
302
|
var REQUEST_TIMEOUT_MS = 15e3;
|
|
285
303
|
async function fetchWithTimeout(url, init) {
|
|
286
304
|
const controller = new AbortController();
|
|
@@ -331,7 +349,7 @@ var ApiClient = class {
|
|
|
331
349
|
headers: this.headers()
|
|
332
350
|
});
|
|
333
351
|
if (!response.ok) {
|
|
334
|
-
throw
|
|
352
|
+
throw failure("getProjectConfig", response);
|
|
335
353
|
}
|
|
336
354
|
return await response.json();
|
|
337
355
|
}
|
|
@@ -342,7 +360,7 @@ var ApiClient = class {
|
|
|
342
360
|
body: JSON.stringify(payload)
|
|
343
361
|
});
|
|
344
362
|
if (!response.ok) {
|
|
345
|
-
throw
|
|
363
|
+
throw failure("ingestLocationEvent", response);
|
|
346
364
|
}
|
|
347
365
|
return await response.json();
|
|
348
366
|
}
|
|
@@ -354,10 +372,10 @@ var ApiClient = class {
|
|
|
354
372
|
headers: this.headers()
|
|
355
373
|
});
|
|
356
374
|
if (!response.ok) {
|
|
357
|
-
throw
|
|
375
|
+
throw failure("getPendingExperiences", response);
|
|
358
376
|
}
|
|
359
377
|
const body = await response.json();
|
|
360
|
-
return body.experiences;
|
|
378
|
+
return { experiences: body.experiences ?? [], revoked: body.revoked ?? [] };
|
|
361
379
|
}
|
|
362
380
|
/** Confirms a delivery was surfaced via `experience.received`, so it stops appearing in future polls. */
|
|
363
381
|
async acknowledgeExperienceDelivery(deliveryId) {
|
|
@@ -367,7 +385,7 @@ var ApiClient = class {
|
|
|
367
385
|
body: JSON.stringify({ deliveryId })
|
|
368
386
|
});
|
|
369
387
|
if (!response.ok) {
|
|
370
|
-
throw
|
|
388
|
+
throw failure("acknowledgeExperienceDelivery", response);
|
|
371
389
|
}
|
|
372
390
|
}
|
|
373
391
|
};
|
|
@@ -396,27 +414,52 @@ function createEventPayload(params) {
|
|
|
396
414
|
}
|
|
397
415
|
|
|
398
416
|
// src/geofencing/state-machine.ts
|
|
417
|
+
var DEFAULT_EXIT_BUFFER_METERS = 20;
|
|
418
|
+
var DEFAULT_MAX_ACCURACY_METERS = 100;
|
|
419
|
+
var DEFAULT_TRANSITION_COOLDOWN_MS = 2 * 6e4;
|
|
399
420
|
var GeofenceStateMachine = class {
|
|
400
421
|
membershipByGeofenceId = /* @__PURE__ */ new Map();
|
|
422
|
+
lastTransitionAtByGeofenceId = /* @__PURE__ */ new Map();
|
|
423
|
+
exitBufferMeters;
|
|
424
|
+
maxAccuracyMeters;
|
|
425
|
+
cooldownMs;
|
|
426
|
+
now;
|
|
427
|
+
constructor(options = {}) {
|
|
428
|
+
this.exitBufferMeters = options.exitBufferMeters ?? DEFAULT_EXIT_BUFFER_METERS;
|
|
429
|
+
this.maxAccuracyMeters = options.maxAccuracyMeters ?? DEFAULT_MAX_ACCURACY_METERS;
|
|
430
|
+
this.cooldownMs = options.cooldownMs ?? DEFAULT_TRANSITION_COOLDOWN_MS;
|
|
431
|
+
this.now = options.now ?? Date.now;
|
|
432
|
+
}
|
|
401
433
|
evaluate(location, geofences) {
|
|
434
|
+
if (location.accuracy !== void 0 && location.accuracy > this.maxAccuracyMeters) {
|
|
435
|
+
return [];
|
|
436
|
+
}
|
|
402
437
|
const transitions = [];
|
|
403
438
|
const activeIds = /* @__PURE__ */ new Set();
|
|
439
|
+
const exitBuffer = Math.max(this.exitBufferMeters, location.accuracy ?? 0);
|
|
440
|
+
const now = this.now();
|
|
404
441
|
for (const geofence of geofences) {
|
|
405
442
|
activeIds.add(geofence.id);
|
|
406
443
|
const distance = calculateDistanceMeters(location, geofence.center);
|
|
407
|
-
const nextMembership = distance <= geofence.radiusMeters ? "INSIDE" : "OUTSIDE";
|
|
408
444
|
const previousMembership = this.membershipByGeofenceId.get(geofence.id) ?? "OUTSIDE";
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
445
|
+
const nextMembership = previousMembership === "INSIDE" ? distance > geofence.radiusMeters + exitBuffer ? "OUTSIDE" : "INSIDE" : distance <= geofence.radiusMeters ? "INSIDE" : "OUTSIDE";
|
|
446
|
+
if (previousMembership === nextMembership) {
|
|
447
|
+
this.membershipByGeofenceId.set(geofence.id, nextMembership);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const lastTransitionAt = this.lastTransitionAtByGeofenceId.get(geofence.id);
|
|
451
|
+
if (lastTransitionAt !== void 0 && now - lastTransitionAt < this.cooldownMs) {
|
|
452
|
+
this.membershipByGeofenceId.set(geofence.id, previousMembership);
|
|
453
|
+
continue;
|
|
414
454
|
}
|
|
415
455
|
this.membershipByGeofenceId.set(geofence.id, nextMembership);
|
|
456
|
+
this.lastTransitionAtByGeofenceId.set(geofence.id, now);
|
|
457
|
+
transitions.push({ geofence, type: nextMembership === "INSIDE" ? "geofence.enter" : "geofence.exit" });
|
|
416
458
|
}
|
|
417
|
-
for (const id of this.membershipByGeofenceId.keys()) {
|
|
459
|
+
for (const id of [...this.membershipByGeofenceId.keys()]) {
|
|
418
460
|
if (!activeIds.has(id)) {
|
|
419
461
|
this.membershipByGeofenceId.delete(id);
|
|
462
|
+
this.lastTransitionAtByGeofenceId.delete(id);
|
|
420
463
|
}
|
|
421
464
|
}
|
|
422
465
|
return transitions;
|
|
@@ -424,8 +467,62 @@ var GeofenceStateMachine = class {
|
|
|
424
467
|
getMembership(geofenceId) {
|
|
425
468
|
return this.membershipByGeofenceId.get(geofenceId) ?? "OUTSIDE";
|
|
426
469
|
}
|
|
470
|
+
isInsideAny() {
|
|
471
|
+
for (const membership of this.membershipByGeofenceId.values()) {
|
|
472
|
+
if (membership === "INSIDE") return true;
|
|
473
|
+
}
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
exportState() {
|
|
477
|
+
return {
|
|
478
|
+
membership: Object.fromEntries(this.membershipByGeofenceId),
|
|
479
|
+
lastTransitionAt: Object.fromEntries(this.lastTransitionAtByGeofenceId)
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/** Only for a machine that has not evaluated anything yet. */
|
|
483
|
+
restoreState(state) {
|
|
484
|
+
this.membershipByGeofenceId.clear();
|
|
485
|
+
this.lastTransitionAtByGeofenceId.clear();
|
|
486
|
+
for (const [id, membership] of Object.entries(state.membership)) {
|
|
487
|
+
if (membership === "INSIDE" || membership === "OUTSIDE") this.membershipByGeofenceId.set(id, membership);
|
|
488
|
+
}
|
|
489
|
+
for (const [id, at] of Object.entries(state.lastTransitionAt)) {
|
|
490
|
+
if (typeof at === "number" && Number.isFinite(at)) this.lastTransitionAtByGeofenceId.set(id, at);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
427
493
|
reset() {
|
|
428
494
|
this.membershipByGeofenceId.clear();
|
|
495
|
+
this.lastTransitionAtByGeofenceId.clear();
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
// src/geofencing/state-store.ts
|
|
500
|
+
var STORAGE_KEY = "@geopulse/geofence-state";
|
|
501
|
+
var GEOFENCE_STATE_MAX_AGE_MS = 12 * 60 * 60 * 1e3;
|
|
502
|
+
var GeofenceStateStore = class {
|
|
503
|
+
constructor(store, now = Date.now) {
|
|
504
|
+
this.store = store;
|
|
505
|
+
this.now = now;
|
|
506
|
+
}
|
|
507
|
+
store;
|
|
508
|
+
now;
|
|
509
|
+
async load() {
|
|
510
|
+
try {
|
|
511
|
+
const raw = await this.store.getItem(STORAGE_KEY);
|
|
512
|
+
if (!raw) return null;
|
|
513
|
+
const saved = JSON.parse(raw);
|
|
514
|
+
if (typeof saved.savedAt !== "number" || this.now() - saved.savedAt > GEOFENCE_STATE_MAX_AGE_MS) return null;
|
|
515
|
+
if (typeof saved.membership !== "object" || saved.membership === null) return null;
|
|
516
|
+
return { membership: saved.membership, lastTransitionAt: saved.lastTransitionAt ?? {} };
|
|
517
|
+
} catch {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
async save(state) {
|
|
522
|
+
try {
|
|
523
|
+
await this.store.setItem(STORAGE_KEY, JSON.stringify({ ...state, savedAt: this.now() }));
|
|
524
|
+
} catch {
|
|
525
|
+
}
|
|
429
526
|
}
|
|
430
527
|
};
|
|
431
528
|
|
|
@@ -492,65 +589,98 @@ var InMemoryEventQueueStore = class {
|
|
|
492
589
|
};
|
|
493
590
|
|
|
494
591
|
// src/storage/queue.ts
|
|
592
|
+
var MAX_QUEUE_SIZE = 100;
|
|
593
|
+
var MAX_QUEUED_EVENT_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
495
594
|
var PendingEventQueue = class {
|
|
496
595
|
constructor(store = new InMemoryEventQueueStore()) {
|
|
497
596
|
this.store = store;
|
|
498
597
|
}
|
|
499
598
|
store;
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
599
|
+
chain = Promise.resolve();
|
|
600
|
+
serialized(operation) {
|
|
601
|
+
const result = this.chain.then(operation, operation);
|
|
602
|
+
this.chain = result.catch(() => void 0);
|
|
603
|
+
return result;
|
|
604
|
+
}
|
|
605
|
+
enqueue(payload) {
|
|
606
|
+
return this.serialized(async () => {
|
|
607
|
+
await this.store.save({
|
|
608
|
+
eventId: payload.id,
|
|
609
|
+
type: payload.type,
|
|
610
|
+
applicationId: payload.applicationId,
|
|
611
|
+
deviceId: payload.deviceId,
|
|
612
|
+
locationId: payload.locationId,
|
|
613
|
+
geofenceId: payload.geofenceId,
|
|
614
|
+
timestamp: payload.timestamp,
|
|
615
|
+
payload,
|
|
616
|
+
attempts: 0,
|
|
617
|
+
status: "pending",
|
|
618
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
619
|
+
lastAttemptAt: null,
|
|
620
|
+
nextAttemptAt: null
|
|
621
|
+
});
|
|
622
|
+
await this.trim();
|
|
515
623
|
});
|
|
516
624
|
}
|
|
517
625
|
/** Every queued event, regardless of retry timing — used for diagnostics/pending counts. */
|
|
518
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
(event
|
|
526
|
-
|
|
626
|
+
listPending() {
|
|
627
|
+
return this.serialized(() => this.store.getAll());
|
|
628
|
+
}
|
|
629
|
+
/** 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. */
|
|
630
|
+
listReadyForRetry(now = /* @__PURE__ */ new Date()) {
|
|
631
|
+
return this.serialized(async () => {
|
|
632
|
+
let all = await this.store.getAll();
|
|
633
|
+
for (const event of all.filter((candidate) => isTooOld(candidate, now.getTime()))) {
|
|
634
|
+
await this.store.remove(event.eventId);
|
|
635
|
+
}
|
|
636
|
+
all = all.filter((event) => !isTooOld(event, now.getTime()));
|
|
637
|
+
return all.filter((event) => event.status !== "sending" && (!event.nextAttemptAt || new Date(event.nextAttemptAt) <= now));
|
|
638
|
+
});
|
|
527
639
|
}
|
|
528
640
|
/** Marks an event as in-flight so a concurrent retry pass won't also pick it up. */
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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()
|
|
641
|
+
markSending(eventId) {
|
|
642
|
+
return this.serialized(async () => {
|
|
643
|
+
const existing = await this.get(eventId);
|
|
644
|
+
if (!existing) return;
|
|
645
|
+
await this.store.save({ ...existing, status: "sending" });
|
|
545
646
|
});
|
|
546
647
|
}
|
|
547
|
-
|
|
548
|
-
|
|
648
|
+
/** `minDelayMs` lets a 429's Retry-After push the next attempt later than the normal backoff. */
|
|
649
|
+
markAttemptFailed(eventId, minDelayMs = 0) {
|
|
650
|
+
return this.serialized(async () => {
|
|
651
|
+
const existing = await this.get(eventId);
|
|
652
|
+
if (!existing) return;
|
|
653
|
+
const attempts = existing.attempts + 1;
|
|
654
|
+
const now = /* @__PURE__ */ new Date();
|
|
655
|
+
await this.store.save({
|
|
656
|
+
...existing,
|
|
657
|
+
status: "failed",
|
|
658
|
+
attempts,
|
|
659
|
+
lastAttemptAt: now.toISOString(),
|
|
660
|
+
nextAttemptAt: new Date(now.getTime() + Math.max(computeRetryDelayMs(attempts), minDelayMs)).toISOString()
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
markSent(eventId) {
|
|
665
|
+
return this.serialized(() => this.store.remove(eventId));
|
|
549
666
|
}
|
|
550
667
|
async get(eventId) {
|
|
551
668
|
return (await this.store.getAll()).find((event) => event.eventId === eventId);
|
|
552
669
|
}
|
|
670
|
+
/** Keeps the queue within MAX_QUEUE_SIZE by dropping the oldest events that are not being sent right now. */
|
|
671
|
+
async trim() {
|
|
672
|
+
const all = await this.store.getAll();
|
|
673
|
+
if (all.length <= MAX_QUEUE_SIZE) return;
|
|
674
|
+
const droppable = all.filter((event) => event.status !== "sending").sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
675
|
+
for (const event of droppable.slice(0, all.length - MAX_QUEUE_SIZE)) {
|
|
676
|
+
await this.store.remove(event.eventId);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
553
679
|
};
|
|
680
|
+
function isTooOld(event, nowMs) {
|
|
681
|
+
const eventTime = Date.parse(event.timestamp);
|
|
682
|
+
return !Number.isNaN(eventTime) && nowMs - eventTime > MAX_QUEUED_EVENT_AGE_MS;
|
|
683
|
+
}
|
|
554
684
|
|
|
555
685
|
// src/sync/network-monitor.ts
|
|
556
686
|
var ManualNetworkMonitor = class {
|
|
@@ -572,20 +702,29 @@ var ManualNetworkMonitor = class {
|
|
|
572
702
|
|
|
573
703
|
// src/sync/sync-manager.ts
|
|
574
704
|
var DEFAULT_RETRY_INTERVAL_MS = 3e4;
|
|
705
|
+
var MAX_UPLOADS_PER_MINUTE = 3;
|
|
706
|
+
var FORCED_RETRY_MIN_GAP_MS = 1e4;
|
|
707
|
+
var FORBIDDEN_PAUSE_MS = MAX_RETRY_AFTER_MS;
|
|
708
|
+
var DEFAULT_RATE_LIMIT_PAUSE_MS = 6e4;
|
|
575
709
|
var SyncManager = class {
|
|
576
|
-
constructor(apiClient, queue, retryIntervalMs = DEFAULT_RETRY_INTERVAL_MS, logger) {
|
|
710
|
+
constructor(apiClient, queue, retryIntervalMs = DEFAULT_RETRY_INTERVAL_MS, logger, onDelivered) {
|
|
577
711
|
this.apiClient = apiClient;
|
|
578
712
|
this.queue = queue;
|
|
579
713
|
this.retryIntervalMs = retryIntervalMs;
|
|
580
714
|
this.logger = logger;
|
|
715
|
+
this.onDelivered = onDelivered;
|
|
581
716
|
}
|
|
582
717
|
apiClient;
|
|
583
718
|
queue;
|
|
584
719
|
retryIntervalMs;
|
|
585
720
|
logger;
|
|
721
|
+
onDelivered;
|
|
586
722
|
retryTimer = null;
|
|
587
723
|
lastSyncAt = null;
|
|
588
724
|
lastUploadResult = null;
|
|
725
|
+
uploadTimes = [];
|
|
726
|
+
pausedUntil = 0;
|
|
727
|
+
lastForcedRetryAt = 0;
|
|
589
728
|
/**
|
|
590
729
|
* Write-ahead: the event is durably queued *before* any network attempt is
|
|
591
730
|
* made, so an app kill mid-request can never lose it (spec: "true
|
|
@@ -597,8 +736,18 @@ var SyncManager = class {
|
|
|
597
736
|
*/
|
|
598
737
|
async sendOrQueue(payload) {
|
|
599
738
|
await this.queue.enqueue(payload);
|
|
739
|
+
if (!this.canUploadNow()) {
|
|
740
|
+
this.logger?.log("Upload deferred (rate cap or server asked to wait)", { eventId: payload.id });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
600
743
|
await this.attemptDelivery(payload.id, payload);
|
|
601
744
|
}
|
|
745
|
+
canUploadNow() {
|
|
746
|
+
const now = Date.now();
|
|
747
|
+
if (now < this.pausedUntil) return false;
|
|
748
|
+
this.uploadTimes = this.uploadTimes.filter((at) => now - at < 6e4);
|
|
749
|
+
return this.uploadTimes.length < MAX_UPLOADS_PER_MINUTE;
|
|
750
|
+
}
|
|
602
751
|
/**
|
|
603
752
|
* Attempts queued events. By default only ones whose backoff window has
|
|
604
753
|
* elapsed (the timer-driven path, `startAutoRetry` — the whole point of
|
|
@@ -611,9 +760,17 @@ var SyncManager = class {
|
|
|
611
760
|
* once it's actually back online.
|
|
612
761
|
*/
|
|
613
762
|
async retryPending(options = {}) {
|
|
614
|
-
|
|
615
|
-
|
|
763
|
+
let force = options.force === true;
|
|
764
|
+
if (force) {
|
|
765
|
+
const now = Date.now();
|
|
766
|
+
if (now - this.lastForcedRetryAt < FORCED_RETRY_MIN_GAP_MS) force = false;
|
|
767
|
+
else this.lastForcedRetryAt = now;
|
|
768
|
+
}
|
|
769
|
+
const ready = force ? (await this.queue.listPending()).filter((event) => event.status !== "sending") : await this.queue.listReadyForRetry();
|
|
770
|
+
const result = { attempted: 0, delivered: 0, failed: 0 };
|
|
616
771
|
for (const event of ready) {
|
|
772
|
+
if (!this.canUploadNow()) break;
|
|
773
|
+
result.attempted++;
|
|
617
774
|
const attemptNumber = event.attempts + 1;
|
|
618
775
|
const delivered = await this.attemptDelivery(event.eventId, { ...event.payload, attempts: attemptNumber });
|
|
619
776
|
if (delivered) {
|
|
@@ -634,21 +791,39 @@ var SyncManager = class {
|
|
|
634
791
|
* was delivered, purely so `retryPending` can tally its `SyncResult`.
|
|
635
792
|
*/
|
|
636
793
|
async attemptDelivery(eventId, payload) {
|
|
794
|
+
this.uploadTimes.push(Date.now());
|
|
637
795
|
await this.queue.markSending(eventId);
|
|
638
796
|
this.logger?.log("Event upload started", { eventId, attempt: payload.attempts });
|
|
797
|
+
let result;
|
|
639
798
|
try {
|
|
640
|
-
await this.apiClient.ingestEvent(payload);
|
|
799
|
+
result = await this.apiClient.ingestEvent(payload);
|
|
641
800
|
await this.queue.markSent(eventId);
|
|
642
801
|
this.lastSyncAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
643
802
|
this.lastUploadResult = "success";
|
|
644
803
|
this.logger?.log("Event upload succeeded", { eventId });
|
|
645
|
-
return true;
|
|
646
804
|
} catch (error) {
|
|
647
|
-
await this.queue.markAttemptFailed(eventId);
|
|
648
805
|
this.lastUploadResult = "failure";
|
|
649
|
-
|
|
806
|
+
if (error instanceof ApiError && error.status === 429) {
|
|
807
|
+
const waitMs = error.retryAfterMs ?? DEFAULT_RATE_LIMIT_PAUSE_MS;
|
|
808
|
+
this.pausedUntil = Date.now() + waitMs;
|
|
809
|
+
await this.queue.markAttemptFailed(eventId, waitMs);
|
|
810
|
+
this.logger?.warn("Server rate limit \u2014 pausing uploads", { eventId, waitMs });
|
|
811
|
+
} else if (error instanceof ApiError && isPermanentRejection(error.status)) {
|
|
812
|
+
await this.queue.markSent(eventId);
|
|
813
|
+
if (error.status === 403) this.pausedUntil = Date.now() + FORBIDDEN_PAUSE_MS;
|
|
814
|
+
this.logger?.warn("Event rejected permanently \u2014 dropped", { eventId, status: error.status });
|
|
815
|
+
} else {
|
|
816
|
+
await this.queue.markAttemptFailed(eventId);
|
|
817
|
+
this.logger?.warn("Event upload failed \u2014 retry scheduled", { eventId, error: String(error) });
|
|
818
|
+
}
|
|
650
819
|
return false;
|
|
651
820
|
}
|
|
821
|
+
try {
|
|
822
|
+
this.onDelivered?.(result);
|
|
823
|
+
} catch (error) {
|
|
824
|
+
this.logger?.warn("onDelivered handler threw", { eventId, error: String(error) });
|
|
825
|
+
}
|
|
826
|
+
return true;
|
|
652
827
|
}
|
|
653
828
|
startAutoRetry() {
|
|
654
829
|
if (this.retryTimer) return;
|
|
@@ -669,6 +844,9 @@ var SyncManager = class {
|
|
|
669
844
|
return this.lastUploadResult;
|
|
670
845
|
}
|
|
671
846
|
};
|
|
847
|
+
function isPermanentRejection(status) {
|
|
848
|
+
return status >= 400 && status < 500 && status !== 408 && status !== 429;
|
|
849
|
+
}
|
|
672
850
|
|
|
673
851
|
// src/core/logger.ts
|
|
674
852
|
var Logger = class {
|
|
@@ -704,7 +882,8 @@ var ApplicationMismatchError = class extends Error {
|
|
|
704
882
|
this.name = "ApplicationMismatchError";
|
|
705
883
|
}
|
|
706
884
|
};
|
|
707
|
-
var DEFAULT_EXPERIENCE_POLL_INTERVAL_MS =
|
|
885
|
+
var DEFAULT_EXPERIENCE_POLL_INTERVAL_MS = 15 * 6e4;
|
|
886
|
+
var CONFIG_REFRESH_INTERVAL_MS = 60 * 6e4;
|
|
708
887
|
var MIN_TIMER_INTERVAL_MS = 1e3;
|
|
709
888
|
function clampIntervalMs(value) {
|
|
710
889
|
if (value === void 0) {
|
|
@@ -731,12 +910,25 @@ var GeoPulseSdk = class {
|
|
|
731
910
|
deviceId = null;
|
|
732
911
|
locationError = null;
|
|
733
912
|
unsubscribeNetwork = null;
|
|
734
|
-
stateMachine
|
|
913
|
+
stateMachine;
|
|
914
|
+
geofenceStateStore;
|
|
915
|
+
geofenceStateRestored = false;
|
|
916
|
+
realtime = null;
|
|
917
|
+
createRealtime;
|
|
918
|
+
lastConfigFetchAt = 0;
|
|
919
|
+
configRefreshInFlight = false;
|
|
735
920
|
listeners = /* @__PURE__ */ new Map();
|
|
736
921
|
experienceListeners = /* @__PURE__ */ new Set();
|
|
737
922
|
/** 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
|
-
|
|
923
|
+
seenExperienceRevisions = /* @__PURE__ */ new Map();
|
|
924
|
+
experienceRemovedListeners = /* @__PURE__ */ new Set();
|
|
925
|
+
removedDeliveryIds = /* @__PURE__ */ new Set();
|
|
739
926
|
experiencePollTimer = null;
|
|
927
|
+
experiencePollInFlight = false;
|
|
928
|
+
/** Set while a poll that was refused with "too many requests" waits to be tried again. */
|
|
929
|
+
pollRetryTimer = null;
|
|
930
|
+
/** Set when a poll was asked for while one was already running; that one may have queried before the new delivery existed. */
|
|
931
|
+
experiencePollRequested = false;
|
|
740
932
|
experiencesReceivedCount = 0;
|
|
741
933
|
logger;
|
|
742
934
|
locationProvider;
|
|
@@ -758,6 +950,9 @@ var GeoPulseSdk = class {
|
|
|
758
950
|
this.networkMonitor = dependencies.networkMonitor ?? new ManualNetworkMonitor();
|
|
759
951
|
this.getPlatform = dependencies.getPlatform ?? (() => "unknown");
|
|
760
952
|
this.logger = new Logger(dependencies.debug ?? false);
|
|
953
|
+
this.stateMachine = new GeofenceStateMachine(dependencies.stateMachineOptions);
|
|
954
|
+
this.geofenceStateStore = new GeofenceStateStore(dependencies.stateStore ?? new InMemoryKeyValueStore());
|
|
955
|
+
this.createRealtime = dependencies.createRealtimeClient;
|
|
761
956
|
}
|
|
762
957
|
/**
|
|
763
958
|
* Resolves this device's persistent id, then fetches (or falls back to
|
|
@@ -783,8 +978,25 @@ var GeoPulseSdk = class {
|
|
|
783
978
|
this.config = config;
|
|
784
979
|
this.experiencePollIntervalMs = clampIntervalMs(config.experiencePollIntervalMs) ?? DEFAULT_EXPERIENCE_POLL_INTERVAL_MS;
|
|
785
980
|
this.apiClient = this.buildApiClient(config);
|
|
786
|
-
this.syncManager = new SyncManager(
|
|
981
|
+
this.syncManager = new SyncManager(
|
|
982
|
+
this.apiClient,
|
|
983
|
+
this.queue,
|
|
984
|
+
clampIntervalMs(config.retryIntervalMs),
|
|
985
|
+
this.logger,
|
|
986
|
+
(result) => this.handleEventDelivered(result)
|
|
987
|
+
);
|
|
787
988
|
this.deviceId = await getOrCreateDeviceId(this.deviceIdStore);
|
|
989
|
+
this.realtime?.disconnect();
|
|
990
|
+
this.realtime = this.createRealtime ? this.createRealtime({
|
|
991
|
+
baseUrl: config.functionsBaseUrl || DEFAULT_BACKEND_URL,
|
|
992
|
+
sdkKey: config.apiKey,
|
|
993
|
+
applicationId: config.applicationId,
|
|
994
|
+
deviceId: this.deviceId,
|
|
995
|
+
onChanged: () => void this.pollForExperiences(),
|
|
996
|
+
// Whatever changed while the socket was down is picked up now.
|
|
997
|
+
onConnected: () => void this.pollForExperiences(),
|
|
998
|
+
logger: this.logger
|
|
999
|
+
}) : null;
|
|
788
1000
|
try {
|
|
789
1001
|
const projectConfig = await this.apiClient.getProjectConfig({
|
|
790
1002
|
deviceId: this.deviceId,
|
|
@@ -796,6 +1008,7 @@ var GeoPulseSdk = class {
|
|
|
796
1008
|
}
|
|
797
1009
|
this.applyProjectConfig(projectConfig);
|
|
798
1010
|
this.configurationStatus = "synced";
|
|
1011
|
+
this.lastConfigFetchAt = Date.now();
|
|
799
1012
|
await this.configCache.save(projectConfig);
|
|
800
1013
|
this.status = "initialized";
|
|
801
1014
|
this.logger.log("Configuration synced", { geofenceCount: projectConfig.geofences.length, configVersion: projectConfig.configVersion });
|
|
@@ -834,6 +1047,11 @@ var GeoPulseSdk = class {
|
|
|
834
1047
|
this.status = "error";
|
|
835
1048
|
throw new Error(`Location permission not granted (status: ${permission})`);
|
|
836
1049
|
}
|
|
1050
|
+
if (!this.geofenceStateRestored) {
|
|
1051
|
+
this.geofenceStateRestored = true;
|
|
1052
|
+
const saved = await this.geofenceStateStore.load();
|
|
1053
|
+
if (saved) this.stateMachine.restoreState(saved);
|
|
1054
|
+
}
|
|
837
1055
|
this.locationError = null;
|
|
838
1056
|
await this.locationProvider.start(
|
|
839
1057
|
(location) => {
|
|
@@ -857,12 +1075,14 @@ var GeoPulseSdk = class {
|
|
|
857
1075
|
this.syncManager?.startAutoRetry();
|
|
858
1076
|
this.startExperiencePolling();
|
|
859
1077
|
this.status = "running";
|
|
1078
|
+
this.syncRealtime();
|
|
860
1079
|
this.logger.log("GeoPulse.start()");
|
|
861
1080
|
}
|
|
862
1081
|
stop() {
|
|
863
1082
|
this.locationProvider.stop();
|
|
864
1083
|
this.syncManager?.stopAutoRetry();
|
|
865
1084
|
this.stopExperiencePolling();
|
|
1085
|
+
this.realtime?.disconnect();
|
|
866
1086
|
this.unsubscribeNetwork?.();
|
|
867
1087
|
this.unsubscribeNetwork = null;
|
|
868
1088
|
this.status = "stopped";
|
|
@@ -879,6 +1099,10 @@ var GeoPulseSdk = class {
|
|
|
879
1099
|
this.experienceListeners.add(callback);
|
|
880
1100
|
return;
|
|
881
1101
|
}
|
|
1102
|
+
if (eventName === "experience.removed") {
|
|
1103
|
+
this.experienceRemovedListeners.add(callback);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
882
1106
|
if (!this.listeners.has(eventName)) {
|
|
883
1107
|
this.listeners.set(eventName, /* @__PURE__ */ new Set());
|
|
884
1108
|
}
|
|
@@ -889,6 +1113,10 @@ var GeoPulseSdk = class {
|
|
|
889
1113
|
this.experienceListeners.delete(callback);
|
|
890
1114
|
return;
|
|
891
1115
|
}
|
|
1116
|
+
if (eventName === "experience.removed") {
|
|
1117
|
+
this.experienceRemovedListeners.delete(callback);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
892
1120
|
this.listeners.get(eventName)?.delete(callback);
|
|
893
1121
|
}
|
|
894
1122
|
// --- Small read-only accessors beyond the core 7-method API, so a host
|
|
@@ -970,28 +1198,64 @@ var GeoPulseSdk = class {
|
|
|
970
1198
|
void this.pollForExperiences();
|
|
971
1199
|
this.experiencePollTimer = setInterval(() => {
|
|
972
1200
|
void this.pollForExperiences();
|
|
1201
|
+
void this.refreshConfigIfStale();
|
|
973
1202
|
}, this.experiencePollIntervalMs);
|
|
974
1203
|
}
|
|
975
1204
|
stopExperiencePolling() {
|
|
1205
|
+
if (this.pollRetryTimer) {
|
|
1206
|
+
clearTimeout(this.pollRetryTimer);
|
|
1207
|
+
this.pollRetryTimer = null;
|
|
1208
|
+
}
|
|
976
1209
|
if (this.experiencePollTimer) {
|
|
977
1210
|
clearInterval(this.experiencePollTimer);
|
|
978
1211
|
this.experiencePollTimer = null;
|
|
979
1212
|
}
|
|
980
1213
|
}
|
|
1214
|
+
/**
|
|
1215
|
+
* The backend says in its ingest response whether the event created an experience for this
|
|
1216
|
+
* device. Poll right away if so (or if it doesn't say — an older backend), skip if it says
|
|
1217
|
+
* there is nothing. Does nothing unless polling is active, so a stopped SDK never polls.
|
|
1218
|
+
*/
|
|
1219
|
+
handleEventDelivered(result) {
|
|
1220
|
+
if (this.experiencePollTimer === null || result?.experiencesPending === false) {
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
void this.pollForExperiences();
|
|
1224
|
+
}
|
|
1225
|
+
/** One poll at a time: a second request while one is running makes it run once more afterwards, rather than overlapping. */
|
|
981
1226
|
async pollForExperiences() {
|
|
1227
|
+
if (this.experiencePollInFlight) {
|
|
1228
|
+
this.experiencePollRequested = true;
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
this.experiencePollInFlight = true;
|
|
1232
|
+
try {
|
|
1233
|
+
do {
|
|
1234
|
+
this.experiencePollRequested = false;
|
|
1235
|
+
await this.pollForExperiencesOnce();
|
|
1236
|
+
} while (this.experiencePollRequested);
|
|
1237
|
+
} finally {
|
|
1238
|
+
this.experiencePollInFlight = false;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
async pollForExperiencesOnce() {
|
|
982
1242
|
if (!this.apiClient || !this.deviceId) {
|
|
983
1243
|
return;
|
|
984
1244
|
}
|
|
985
1245
|
try {
|
|
986
|
-
const experiences = await this.apiClient.getPendingExperiences(this.deviceId);
|
|
1246
|
+
const { experiences, revoked } = await this.apiClient.getPendingExperiences(this.deviceId);
|
|
987
1247
|
for (const experience of experiences) {
|
|
988
|
-
|
|
1248
|
+
const revision = experience.revision ?? 0;
|
|
1249
|
+
const seenRevision = this.seenExperienceRevisions.get(experience.deliveryId);
|
|
1250
|
+
if (seenRevision === revision) {
|
|
989
1251
|
continue;
|
|
990
1252
|
}
|
|
991
|
-
|
|
1253
|
+
const isUpdate = seenRevision !== void 0;
|
|
1254
|
+
this.seenExperienceRevisions.set(experience.deliveryId, revision);
|
|
1255
|
+
this.removedDeliveryIds.delete(experience.deliveryId);
|
|
992
1256
|
this.experiencesReceivedCount += 1;
|
|
993
|
-
this.logger.log("Experience received", { deliveryId: experience.deliveryId, campaignId: experience.campaignId });
|
|
994
|
-
this.experienceListeners.forEach((listener) => listener(experience));
|
|
1257
|
+
this.logger.log("Experience received", { deliveryId: experience.deliveryId, campaignId: experience.campaignId, isUpdate });
|
|
1258
|
+
this.experienceListeners.forEach((listener) => listener({ ...experience, isUpdate }));
|
|
995
1259
|
await this.apiClient.acknowledgeExperienceDelivery(experience.deliveryId).catch((error) => {
|
|
996
1260
|
this.logger.warn("Failed to acknowledge experience delivery", {
|
|
997
1261
|
deliveryId: experience.deliveryId,
|
|
@@ -999,10 +1263,84 @@ var GeoPulseSdk = class {
|
|
|
999
1263
|
});
|
|
1000
1264
|
});
|
|
1001
1265
|
}
|
|
1266
|
+
for (const removed of revoked) {
|
|
1267
|
+
if (!this.removedDeliveryIds.has(removed.deliveryId)) {
|
|
1268
|
+
this.removedDeliveryIds.add(removed.deliveryId);
|
|
1269
|
+
this.seenExperienceRevisions.delete(removed.deliveryId);
|
|
1270
|
+
this.logger.log("Experience removed", { deliveryId: removed.deliveryId, campaignId: removed.campaignId });
|
|
1271
|
+
this.experienceRemovedListeners.forEach((listener) => listener(removed));
|
|
1272
|
+
}
|
|
1273
|
+
await this.apiClient.acknowledgeExperienceDelivery(removed.deliveryId).catch((error) => {
|
|
1274
|
+
this.logger.warn("Failed to acknowledge experience removal", {
|
|
1275
|
+
deliveryId: removed.deliveryId,
|
|
1276
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1277
|
+
});
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1002
1280
|
} catch (error) {
|
|
1003
1281
|
this.logger.warn("Polling for experiences failed", { error: error instanceof Error ? error.message : String(error) });
|
|
1282
|
+
if (error instanceof ApiError && error.status === 429) {
|
|
1283
|
+
this.schedulePollRetry(error.retryAfterMs ?? 3e4);
|
|
1284
|
+
}
|
|
1004
1285
|
}
|
|
1005
1286
|
}
|
|
1287
|
+
schedulePollRetry(waitMs) {
|
|
1288
|
+
if (this.pollRetryTimer !== null || this.experiencePollTimer === null) {
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
this.pollRetryTimer = setTimeout(() => {
|
|
1292
|
+
this.pollRetryTimer = null;
|
|
1293
|
+
void this.pollForExperiences();
|
|
1294
|
+
}, waitMs + Math.random() * 5e3);
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Re-reads the geofence list when it is an hour old, so a geofence an admin added, moved, resized or removed reaches a phone
|
|
1298
|
+
* 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.
|
|
1299
|
+
* A failure keeps the list we have. A disabled application stops the SDK.
|
|
1300
|
+
*/
|
|
1301
|
+
async refreshConfigIfStale() {
|
|
1302
|
+
if (!this.apiClient || !this.deviceId || !this.config || this.configRefreshInFlight) {
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
if (Date.now() - this.lastConfigFetchAt < CONFIG_REFRESH_INTERVAL_MS) {
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
this.configRefreshInFlight = true;
|
|
1309
|
+
try {
|
|
1310
|
+
const projectConfig = await this.apiClient.getProjectConfig({ deviceId: this.deviceId, platform: this.getPlatform(), sdkVersion: SDK_VERSION });
|
|
1311
|
+
if (projectConfig.applicationId !== this.config.applicationId) {
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
this.lastConfigFetchAt = Date.now();
|
|
1315
|
+
this.applyProjectConfig(projectConfig);
|
|
1316
|
+
this.configurationStatus = "synced";
|
|
1317
|
+
await this.configCache.save(projectConfig);
|
|
1318
|
+
this.logger.log("Configuration refreshed", { geofenceCount: projectConfig.geofences.length, configVersion: projectConfig.configVersion });
|
|
1319
|
+
if (!projectConfig.applicationEnabled) {
|
|
1320
|
+
this.logger.warn("Application disabled \u2014 stopping");
|
|
1321
|
+
this.stop();
|
|
1322
|
+
this.status = "error";
|
|
1323
|
+
}
|
|
1324
|
+
} catch (error) {
|
|
1325
|
+
this.logger.warn("Configuration refresh failed \u2014 keeping the current geofences", { error: error instanceof Error ? error.message : String(error) });
|
|
1326
|
+
} finally {
|
|
1327
|
+
this.configRefreshInFlight = false;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
/** Keeps the push connection open exactly while the user is inside a geofence. */
|
|
1331
|
+
syncRealtime() {
|
|
1332
|
+
if (!this.realtime || this.status === "stopped") {
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (this.stateMachine.isInsideAny()) {
|
|
1336
|
+
this.realtime.connect();
|
|
1337
|
+
} else {
|
|
1338
|
+
this.realtime.disconnect();
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
async persistGeofenceState() {
|
|
1342
|
+
await this.geofenceStateStore.save(this.stateMachine.exportState());
|
|
1343
|
+
}
|
|
1006
1344
|
applyProjectConfig(projectConfig) {
|
|
1007
1345
|
this.resolvedProjectId = projectConfig.projectId;
|
|
1008
1346
|
this.resolvedApplicationId = projectConfig.applicationId;
|
|
@@ -1016,6 +1354,10 @@ var GeoPulseSdk = class {
|
|
|
1016
1354
|
return;
|
|
1017
1355
|
}
|
|
1018
1356
|
const transitions = this.stateMachine.evaluate(location, this.geofences);
|
|
1357
|
+
if (transitions.length > 0) {
|
|
1358
|
+
void this.persistGeofenceState();
|
|
1359
|
+
}
|
|
1360
|
+
this.syncRealtime();
|
|
1019
1361
|
for (const transition of transitions) {
|
|
1020
1362
|
this.logger.log("Geofence state changed", { geofenceId: transition.geofence.id, type: transition.type });
|
|
1021
1363
|
const event = createEventPayload({
|
|
@@ -1054,7 +1396,8 @@ var DeviceLocationProvider = class {
|
|
|
1054
1396
|
onUpdate({
|
|
1055
1397
|
latitude: position.coords.latitude,
|
|
1056
1398
|
longitude: position.coords.longitude,
|
|
1057
|
-
timestamp: new Date(position.timestamp).toISOString()
|
|
1399
|
+
timestamp: new Date(position.timestamp).toISOString(),
|
|
1400
|
+
...typeof position.coords.accuracy === "number" ? { accuracy: position.coords.accuracy } : {}
|
|
1058
1401
|
});
|
|
1059
1402
|
}
|
|
1060
1403
|
);
|
|
@@ -1093,10 +1436,10 @@ var ExpoPermissionProvider = class {
|
|
|
1093
1436
|
|
|
1094
1437
|
// src/storage/async-storage-store.ts
|
|
1095
1438
|
var import_async_storage2 = __toESM(require("@react-native-async-storage/async-storage"));
|
|
1096
|
-
var
|
|
1439
|
+
var STORAGE_KEY2 = "@geopulse/pending-events";
|
|
1097
1440
|
var AsyncStorageEventQueueStore = class {
|
|
1098
1441
|
async getAll() {
|
|
1099
|
-
const raw = await import_async_storage2.default.getItem(
|
|
1442
|
+
const raw = await import_async_storage2.default.getItem(STORAGE_KEY2);
|
|
1100
1443
|
if (!raw) return [];
|
|
1101
1444
|
try {
|
|
1102
1445
|
return JSON.parse(raw);
|
|
@@ -1107,14 +1450,112 @@ var AsyncStorageEventQueueStore = class {
|
|
|
1107
1450
|
async save(event) {
|
|
1108
1451
|
const all = await this.getAll();
|
|
1109
1452
|
const next = [...all.filter((existing) => existing.eventId !== event.eventId), event];
|
|
1110
|
-
await import_async_storage2.default.setItem(
|
|
1453
|
+
await import_async_storage2.default.setItem(STORAGE_KEY2, JSON.stringify(next));
|
|
1111
1454
|
}
|
|
1112
1455
|
async remove(eventId) {
|
|
1113
1456
|
const all = await this.getAll();
|
|
1114
|
-
await import_async_storage2.default.setItem(
|
|
1457
|
+
await import_async_storage2.default.setItem(STORAGE_KEY2, JSON.stringify(all.filter((existing) => existing.eventId !== eventId)));
|
|
1115
1458
|
}
|
|
1116
1459
|
};
|
|
1117
1460
|
|
|
1461
|
+
// src/realtime/realtime-client.ts
|
|
1462
|
+
var RECONNECT_BASE_MS = 5e3;
|
|
1463
|
+
var RECONNECT_MAX_MS = 5 * 6e4;
|
|
1464
|
+
var NO_RETRY_CODES = /* @__PURE__ */ new Set([4400, 4401, 4403]);
|
|
1465
|
+
function toRealtimeUrl(baseUrl) {
|
|
1466
|
+
return `${baseUrl.replace(/\/+$/, "").replace(/^http/, "ws")}/realtime`;
|
|
1467
|
+
}
|
|
1468
|
+
var RealtimeClient = class {
|
|
1469
|
+
constructor(options) {
|
|
1470
|
+
this.options = options;
|
|
1471
|
+
}
|
|
1472
|
+
options;
|
|
1473
|
+
socket = null;
|
|
1474
|
+
connected = false;
|
|
1475
|
+
wanted = false;
|
|
1476
|
+
failures = 0;
|
|
1477
|
+
reconnectTimer = null;
|
|
1478
|
+
isConnected() {
|
|
1479
|
+
return this.connected;
|
|
1480
|
+
}
|
|
1481
|
+
connect() {
|
|
1482
|
+
this.wanted = true;
|
|
1483
|
+
if (this.socket || this.reconnectTimer) return;
|
|
1484
|
+
this.open();
|
|
1485
|
+
}
|
|
1486
|
+
disconnect() {
|
|
1487
|
+
this.wanted = false;
|
|
1488
|
+
this.failures = 0;
|
|
1489
|
+
if (this.reconnectTimer) {
|
|
1490
|
+
clearTimeout(this.reconnectTimer);
|
|
1491
|
+
this.reconnectTimer = null;
|
|
1492
|
+
}
|
|
1493
|
+
const socket = this.socket;
|
|
1494
|
+
this.socket = null;
|
|
1495
|
+
this.connected = false;
|
|
1496
|
+
if (socket) {
|
|
1497
|
+
socket.onclose = null;
|
|
1498
|
+
socket.onmessage = null;
|
|
1499
|
+
socket.onerror = null;
|
|
1500
|
+
try {
|
|
1501
|
+
socket.close(1e3, "done");
|
|
1502
|
+
} catch {
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
open() {
|
|
1507
|
+
const create = this.options.createSocket ?? defaultCreateSocket;
|
|
1508
|
+
let socket;
|
|
1509
|
+
try {
|
|
1510
|
+
socket = create(toRealtimeUrl(this.options.baseUrl));
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
this.options.logger?.warn("Realtime unavailable", { error: String(error) });
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
if (!socket) return;
|
|
1516
|
+
this.socket = socket;
|
|
1517
|
+
socket.onopen = () => {
|
|
1518
|
+
socket.send(JSON.stringify({ type: "hello", sdkKey: this.options.sdkKey, applicationId: this.options.applicationId, deviceId: this.options.deviceId }));
|
|
1519
|
+
};
|
|
1520
|
+
socket.onmessage = (event) => {
|
|
1521
|
+
let message = null;
|
|
1522
|
+
try {
|
|
1523
|
+
message = JSON.parse(String(event.data));
|
|
1524
|
+
} catch {
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
if (message?.type === "ready") {
|
|
1528
|
+
this.connected = true;
|
|
1529
|
+
this.failures = 0;
|
|
1530
|
+
this.options.onConnected();
|
|
1531
|
+
} else if (message?.type === "changed") {
|
|
1532
|
+
this.options.onChanged();
|
|
1533
|
+
}
|
|
1534
|
+
};
|
|
1535
|
+
socket.onerror = () => {
|
|
1536
|
+
};
|
|
1537
|
+
socket.onclose = (event) => {
|
|
1538
|
+
this.socket = null;
|
|
1539
|
+
this.connected = false;
|
|
1540
|
+
if (!this.wanted) return;
|
|
1541
|
+
if (NO_RETRY_CODES.has(event.code)) {
|
|
1542
|
+
this.options.logger?.warn("Realtime rejected by the server; not retrying", { code: event.code });
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
this.failures += 1;
|
|
1546
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** (this.failures - 1), RECONNECT_MAX_MS);
|
|
1547
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1548
|
+
this.reconnectTimer = null;
|
|
1549
|
+
if (this.wanted) this.open();
|
|
1550
|
+
}, Math.round(delay * (0.8 + Math.random() * 0.4)));
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
function defaultCreateSocket(url) {
|
|
1555
|
+
const Ctor = globalThis.WebSocket;
|
|
1556
|
+
return Ctor ? new Ctor(url) : null;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1118
1559
|
// src/sync/netinfo-network-monitor.ts
|
|
1119
1560
|
var import_netinfo = __toESM(require("@react-native-community/netinfo"));
|
|
1120
1561
|
function toStatus(isConnected) {
|
|
@@ -1252,6 +1693,11 @@ var sdk = new GeoPulseSdk({
|
|
|
1252
1693
|
queue: new PendingEventQueue(new AsyncStorageEventQueueStore()),
|
|
1253
1694
|
deviceIdStore: persistentKeyValueStore,
|
|
1254
1695
|
configCacheStore: persistentKeyValueStore,
|
|
1696
|
+
// The simulator is for trying things out: no 2-minute cooldown, and no saved "inside" state surviving a reload
|
|
1697
|
+
// (otherwise "enter" would not fire again after Fast Refresh while the saved state says you are still inside).
|
|
1698
|
+
stateStore: useSimulatedProviders ? void 0 : persistentKeyValueStore,
|
|
1699
|
+
stateMachineOptions: useSimulatedProviders ? { cooldownMs: 0 } : void 0,
|
|
1700
|
+
createRealtimeClient: (options) => new RealtimeClient(options),
|
|
1255
1701
|
networkMonitor: new NetInfoNetworkMonitor(),
|
|
1256
1702
|
getPlatform,
|
|
1257
1703
|
debug: isDev
|