@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/README.md +148 -15
- package/dist/index.d.ts +159 -12
- package/dist/index.js +543 -78
- package/dist/index.js.map +1 -1
- package/package.json +20 -17
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1256
|
+
const revision = experience.revision ?? 0;
|
|
1257
|
+
const seenRevision = this.seenExperienceRevisions.get(experience.deliveryId);
|
|
1258
|
+
if (seenRevision === revision) {
|
|
989
1259
|
continue;
|
|
990
1260
|
}
|
|
991
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|