@apex-inc/capacitor-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/ApexCapacitorPlugin.podspec +17 -0
  2. package/LICENSE +17 -0
  3. package/README.md +136 -0
  4. package/android/build.gradle +68 -0
  5. package/android/src/main/AndroidManifest.xml +8 -0
  6. package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +325 -0
  7. package/android/src/main/java/inc/apex/capacitor/DeepLinkManager.kt +47 -0
  8. package/android/src/main/java/inc/apex/capacitor/InstallReferrerParser.kt +123 -0
  9. package/android/src/main/java/inc/apex/capacitor/OfflineQueue.kt +150 -0
  10. package/android/src/main/java/inc/apex/capacitor/SessionManager.kt +108 -0
  11. package/dist/batch-sender.d.ts +60 -0
  12. package/dist/batch-sender.d.ts.map +1 -0
  13. package/dist/batch-sender.js +115 -0
  14. package/dist/batch-sender.js.map +1 -0
  15. package/dist/definitions.d.ts +224 -0
  16. package/dist/definitions.d.ts.map +1 -0
  17. package/dist/definitions.js +14 -0
  18. package/dist/definitions.js.map +1 -0
  19. package/dist/esm/batch-sender.d.ts +60 -0
  20. package/dist/esm/batch-sender.d.ts.map +1 -0
  21. package/dist/esm/batch-sender.js +111 -0
  22. package/dist/esm/batch-sender.js.map +1 -0
  23. package/dist/esm/definitions.d.ts +224 -0
  24. package/dist/esm/definitions.d.ts.map +1 -0
  25. package/dist/esm/definitions.js +13 -0
  26. package/dist/esm/definitions.js.map +1 -0
  27. package/dist/esm/event-id.d.ts +17 -0
  28. package/dist/esm/event-id.d.ts.map +1 -0
  29. package/dist/esm/event-id.js +57 -0
  30. package/dist/esm/event-id.js.map +1 -0
  31. package/dist/esm/index.d.ts +29 -0
  32. package/dist/esm/index.d.ts.map +1 -0
  33. package/dist/esm/index.js +30 -0
  34. package/dist/esm/index.js.map +1 -0
  35. package/dist/esm/offline-queue.d.ts +111 -0
  36. package/dist/esm/offline-queue.d.ts.map +1 -0
  37. package/dist/esm/offline-queue.js +240 -0
  38. package/dist/esm/offline-queue.js.map +1 -0
  39. package/dist/esm/session-manager.d.ts +63 -0
  40. package/dist/esm/session-manager.d.ts.map +1 -0
  41. package/dist/esm/session-manager.js +100 -0
  42. package/dist/esm/session-manager.js.map +1 -0
  43. package/dist/esm/web.d.ts +65 -0
  44. package/dist/esm/web.d.ts.map +1 -0
  45. package/dist/esm/web.js +203 -0
  46. package/dist/esm/web.js.map +1 -0
  47. package/dist/event-id.d.ts +17 -0
  48. package/dist/event-id.d.ts.map +1 -0
  49. package/dist/event-id.js +61 -0
  50. package/dist/event-id.js.map +1 -0
  51. package/dist/index.d.ts +29 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +76 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/offline-queue.d.ts +111 -0
  56. package/dist/offline-queue.d.ts.map +1 -0
  57. package/dist/offline-queue.js +246 -0
  58. package/dist/offline-queue.js.map +1 -0
  59. package/dist/session-manager.d.ts +63 -0
  60. package/dist/session-manager.d.ts.map +1 -0
  61. package/dist/session-manager.js +104 -0
  62. package/dist/session-manager.js.map +1 -0
  63. package/dist/web.d.ts +65 -0
  64. package/dist/web.d.ts.map +1 -0
  65. package/dist/web.js +207 -0
  66. package/dist/web.js.map +1 -0
  67. package/ios/Package.swift +34 -0
  68. package/ios/Sources/ApexCapacitorPlugin/AdvertisingIdProvider.swift +66 -0
  69. package/ios/Sources/ApexCapacitorPlugin/AttManager.swift +82 -0
  70. package/ios/Sources/ApexCapacitorPlugin/DeepLinkManager.swift +64 -0
  71. package/ios/Sources/ApexCapacitorPlugin/DeviceInfo.swift +107 -0
  72. package/ios/Sources/ApexCapacitorPlugin/OfflineQueue.swift +191 -0
  73. package/ios/Sources/ApexCapacitorPlugin/SessionManager.swift +113 -0
  74. package/ios/Sources/ApexCapacitorPlugin/SkanManager.swift +95 -0
  75. package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +269 -0
  76. package/ios/Tests/ApexCapacitorPluginTests/AdvertisingIdProviderTests.swift +74 -0
  77. package/ios/Tests/ApexCapacitorPluginTests/AttManagerTests.swift +82 -0
  78. package/ios/Tests/ApexCapacitorPluginTests/DeepLinkManagerTests.swift +69 -0
  79. package/ios/Tests/ApexCapacitorPluginTests/DeviceInfoTests.swift +52 -0
  80. package/ios/Tests/ApexCapacitorPluginTests/OfflineQueueTests.swift +134 -0
  81. package/ios/Tests/ApexCapacitorPluginTests/SessionManagerTests.swift +98 -0
  82. package/ios/Tests/ApexCapacitorPluginTests/SkanManagerTests.swift +91 -0
  83. package/package.json +82 -0
@@ -0,0 +1,123 @@
1
+ package inc.apex.capacitor
2
+
3
+ /**
4
+ * Parses Play Install Referrer strings into a map of click-ID claims.
5
+ *
6
+ * Android's Play Install Referrer API returns a URL-encoded key=value&... string
7
+ * that carries the ad-network click IDs used for attribution. We extract the
8
+ * fields the server expects (gclid, fbclid, ttclid, msclkid, li_fat_id, wbraid,
9
+ * gbraid, and utm_* parameters) and surface them as a typed Kotlin map.
10
+ *
11
+ * This is the single most important piece of Android attribution — without it,
12
+ * all paid installs appear as organic and ROAS is meaningless.
13
+ *
14
+ * Pure-logic Kotlin: no Android runtime imports so it runs under plain JUnit.
15
+ */
16
+ object InstallReferrerParser {
17
+
18
+ /** Known click-ID keys we always surface at the top level of the result. */
19
+ private val KNOWN_CLICK_IDS = setOf(
20
+ "gclid",
21
+ "fbclid",
22
+ "ttclid", // TikTok
23
+ "msclkid", // Microsoft / Bing Ads
24
+ "li_fat_id", // LinkedIn
25
+ "wbraid", // Google App Campaigns web-to-app
26
+ "gbraid", // Google App Campaigns app-to-app
27
+ "epik", // Pinterest
28
+ "rdt_cid", // Reddit
29
+ "twclid" // Twitter / X
30
+ )
31
+
32
+ private val KNOWN_UTM_KEYS = setOf(
33
+ "utm_source",
34
+ "utm_medium",
35
+ "utm_campaign",
36
+ "utm_term",
37
+ "utm_content"
38
+ )
39
+
40
+ /** Parsed referrer claims — populated fields + any extras the ad network sent. */
41
+ data class Claims(
42
+ val raw: String,
43
+ val clickIds: Map<String, String>,
44
+ val utm: Map<String, String>,
45
+ val other: Map<String, String>
46
+ ) {
47
+ /** Convenience for the most common access pattern. */
48
+ fun hasAnyClickId(): Boolean = clickIds.isNotEmpty()
49
+ fun hasAnyAttribution(): Boolean = clickIds.isNotEmpty() || utm.isNotEmpty()
50
+ }
51
+
52
+ /**
53
+ * Parse a raw referrer string into structured claims. An empty or null
54
+ * input returns an empty Claims object — the install is treated as organic.
55
+ */
56
+ fun parse(raw: String?): Claims {
57
+ val safeRaw = raw?.trim().orEmpty()
58
+ if (safeRaw.isEmpty()) {
59
+ return Claims(raw = "", clickIds = emptyMap(), utm = emptyMap(), other = emptyMap())
60
+ }
61
+
62
+ val clicks = mutableMapOf<String, String>()
63
+ val utm = mutableMapOf<String, String>()
64
+ val other = mutableMapOf<String, String>()
65
+
66
+ for (pair in splitPairs(safeRaw)) {
67
+ val key = pair.first.lowercase()
68
+ val value = pair.second
69
+ if (value.isEmpty()) continue
70
+
71
+ when {
72
+ KNOWN_CLICK_IDS.contains(key) -> clicks[key] = value
73
+ KNOWN_UTM_KEYS.contains(key) -> utm[key] = value
74
+ else -> other[key] = value
75
+ }
76
+ }
77
+
78
+ return Claims(raw = safeRaw, clickIds = clicks, utm = utm, other = other)
79
+ }
80
+
81
+ private fun splitPairs(raw: String): List<Pair<String, String>> {
82
+ val decoded = urlDecode(raw)
83
+ return decoded.split("&")
84
+ .mapNotNull { segment ->
85
+ val idx = segment.indexOf('=')
86
+ if (idx <= 0) return@mapNotNull null
87
+ val k = segment.substring(0, idx)
88
+ val v = segment.substring(idx + 1)
89
+ if (k.isBlank()) return@mapNotNull null
90
+ urlDecode(k) to urlDecode(v)
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Decode percent-encoded strings and `+` as space. We implement this
96
+ * manually rather than using `URLDecoder` to keep the class dependency-free
97
+ * for JUnit tests.
98
+ */
99
+ internal fun urlDecode(value: String): String {
100
+ if (value.isEmpty()) return value
101
+ val sb = StringBuilder(value.length)
102
+ var i = 0
103
+ while (i < value.length) {
104
+ val c = value[i]
105
+ when {
106
+ c == '+' -> sb.append(' ')
107
+ c == '%' && i + 2 < value.length -> {
108
+ val hex = value.substring(i + 1, i + 3)
109
+ val byte = hex.toIntOrNull(16)
110
+ if (byte != null) {
111
+ sb.append(byte.toChar())
112
+ i += 2
113
+ } else {
114
+ sb.append(c)
115
+ }
116
+ }
117
+ else -> sb.append(c)
118
+ }
119
+ i++
120
+ }
121
+ return sb.toString()
122
+ }
123
+ }
@@ -0,0 +1,150 @@
1
+ package inc.apex.capacitor
2
+
3
+ import org.json.JSONArray
4
+ import org.json.JSONObject
5
+
6
+ /**
7
+ * Durable event queue backed by a plain JSON file inside the app's internal
8
+ * storage directory. File-based storage is pragmatic for our event scale
9
+ * (a few hundred to low thousands of events max) — Room's migration machinery
10
+ * is overkill.
11
+ *
12
+ * Thread-safe via an intrinsic lock. The JS-side queue is the source of truth;
13
+ * this native queue exists so events survive the app being killed while the
14
+ * JS runtime is frozen.
15
+ *
16
+ * Pure-Kotlin-plus-org.json — runs under JUnit with a FileStorage abstraction
17
+ * that tests can swap for in-memory without any Android runtime.
18
+ */
19
+ data class NativeQueuedEvent(
20
+ val id: String,
21
+ val payload: Map<String, Any?>,
22
+ val attempts: Int,
23
+ val enqueuedAtMs: Long,
24
+ )
25
+
26
+ interface QueueStorage {
27
+ fun readAll(): String?
28
+ fun writeAll(contents: String)
29
+ }
30
+
31
+ class NativeOfflineQueue(
32
+ private val storage: QueueStorage,
33
+ private val maxSize: Int = 1000,
34
+ ) {
35
+ init {
36
+ require(maxSize >= 1) { "maxSize must be >= 1, got $maxSize" }
37
+ }
38
+
39
+ private val lock = Any()
40
+
41
+ fun enqueue(event: NativeQueuedEvent) {
42
+ require(event.id.isNotEmpty()) { "event.id is required" }
43
+ synchronized(lock) {
44
+ val events = readLocked().toMutableList()
45
+ events.add(event)
46
+ if (events.size > maxSize) {
47
+ val overflow = events.size - maxSize
48
+ events.subList(0, overflow).clear()
49
+ }
50
+ writeLocked(events)
51
+ }
52
+ }
53
+
54
+ fun peek(batchSize: Int): List<NativeQueuedEvent> {
55
+ if (batchSize < 1) return emptyList()
56
+ return synchronized(lock) {
57
+ readLocked().take(batchSize)
58
+ }
59
+ }
60
+
61
+ fun markSent(ids: List<String>) {
62
+ if (ids.isEmpty()) return
63
+ synchronized(lock) {
64
+ val idSet = ids.toSet()
65
+ val kept = readLocked().filterNot { idSet.contains(it.id) }
66
+ writeLocked(kept)
67
+ }
68
+ }
69
+
70
+ fun markFailed(ids: List<String>) {
71
+ if (ids.isEmpty()) return
72
+ synchronized(lock) {
73
+ val idSet = ids.toSet()
74
+ val next = readLocked().map {
75
+ if (idSet.contains(it.id)) it.copy(attempts = it.attempts + 1) else it
76
+ }
77
+ writeLocked(next)
78
+ }
79
+ }
80
+
81
+ fun size(): Int = synchronized(lock) { readLocked().size }
82
+
83
+ fun oldestEventAtMs(): Long? = synchronized(lock) {
84
+ readLocked().firstOrNull()?.enqueuedAtMs
85
+ }
86
+
87
+ fun clear() = synchronized(lock) { writeLocked(emptyList()) }
88
+
89
+ // ── Serialization ──────────────────────────────────────────────
90
+
91
+ private fun readLocked(): List<NativeQueuedEvent> {
92
+ val raw = storage.readAll() ?: return emptyList()
93
+ if (raw.isBlank()) return emptyList()
94
+ return try {
95
+ val array = JSONArray(raw)
96
+ val result = mutableListOf<NativeQueuedEvent>()
97
+ for (i in 0 until array.length()) {
98
+ val obj = array.getJSONObject(i)
99
+ val payloadObj = obj.optJSONObject("payload")
100
+ val payload = payloadObj?.let { jsonObjectToMap(it) } ?: emptyMap()
101
+ result.add(
102
+ NativeQueuedEvent(
103
+ id = obj.getString("id"),
104
+ payload = payload,
105
+ attempts = obj.optInt("attempts", 0),
106
+ enqueuedAtMs = obj.optLong("enqueuedAtMs", 0L),
107
+ )
108
+ )
109
+ }
110
+ result
111
+ } catch (_: Exception) {
112
+ emptyList()
113
+ }
114
+ }
115
+
116
+ private fun writeLocked(events: List<NativeQueuedEvent>) {
117
+ val array = JSONArray()
118
+ for (ev in events) {
119
+ val obj = JSONObject()
120
+ obj.put("id", ev.id)
121
+ obj.put("attempts", ev.attempts)
122
+ obj.put("enqueuedAtMs", ev.enqueuedAtMs)
123
+ obj.put("payload", JSONObject(ev.payload))
124
+ array.put(obj)
125
+ }
126
+ storage.writeAll(array.toString())
127
+ }
128
+
129
+ private fun jsonObjectToMap(obj: JSONObject): Map<String, Any?> {
130
+ val result = mutableMapOf<String, Any?>()
131
+ val keys = obj.keys()
132
+ while (keys.hasNext()) {
133
+ val key = keys.next()
134
+ result[key] = obj.opt(key)
135
+ }
136
+ return result
137
+ }
138
+ }
139
+
140
+ /**
141
+ * In-memory storage backend for tests.
142
+ */
143
+ class InMemoryQueueStorage : QueueStorage {
144
+ private var contents: String? = null
145
+
146
+ override fun readAll(): String? = contents
147
+ override fun writeAll(contents: String) {
148
+ this.contents = contents
149
+ }
150
+ }
@@ -0,0 +1,108 @@
1
+ package inc.apex.capacitor
2
+
3
+ import java.util.UUID
4
+ import java.util.concurrent.TimeUnit
5
+
6
+ /**
7
+ * Native Android session manager. Mirrors the TS and Swift session managers —
8
+ * a session starts on the first activity and ends after `timeoutMinutes` of
9
+ * inactivity (default 30). The JS bridge also runs a session manager; this
10
+ * native copy catches background-state transitions that JS timers can't
11
+ * observe after Doze / App Standby kicks in.
12
+ *
13
+ * Pure Kotlin — injectable clock + scheduler keep unit tests deterministic.
14
+ */
15
+ data class SessionSnapshot(
16
+ val sessionId: String,
17
+ val startedAtMs: Long,
18
+ var lastActivityMs: Long,
19
+ var eventCount: Int,
20
+ var endedAtMs: Long? = null,
21
+ var durationSeconds: Long? = null,
22
+ )
23
+
24
+ fun interface Scheduler {
25
+ /** Schedule a one-shot callback after `delayMs`, returns a cancel token. */
26
+ fun schedule(delayMs: Long, callback: () -> Unit): Any
27
+
28
+ companion object {
29
+ /** Default implementation — no-op scheduler used as a fallback in tests. */
30
+ val Noop: Scheduler = Scheduler { _, _ -> Any() }
31
+ }
32
+ }
33
+
34
+ fun interface ScheduleCanceller {
35
+ fun cancel(token: Any)
36
+
37
+ companion object {
38
+ val Noop: ScheduleCanceller = ScheduleCanceller { _ -> }
39
+ }
40
+ }
41
+
42
+ class NativeSessionManager(
43
+ private val timeoutMinutes: Int = 30,
44
+ private val now: () -> Long = { System.currentTimeMillis() },
45
+ private val scheduler: Scheduler = Scheduler.Noop,
46
+ private val canceller: ScheduleCanceller = ScheduleCanceller.Noop,
47
+ private val onStart: ((SessionSnapshot) -> Unit)? = null,
48
+ private val onEnd: ((SessionSnapshot) -> Unit)? = null,
49
+ ) {
50
+ private val timeoutMs: Long = TimeUnit.MINUTES.toMillis(timeoutMinutes.toLong())
51
+ private var current: SessionSnapshot? = null
52
+ private var cancelToken: Any? = null
53
+ private val lock = Any()
54
+
55
+ fun recordActivity(): SessionSnapshot {
56
+ synchronized(lock) {
57
+ val nowMs = now()
58
+ if (current == null) {
59
+ val snapshot = SessionSnapshot(
60
+ sessionId = UUID.randomUUID().toString().lowercase(),
61
+ startedAtMs = nowMs,
62
+ lastActivityMs = nowMs,
63
+ eventCount = 1,
64
+ )
65
+ current = snapshot
66
+ onStart?.invoke(snapshot.copy())
67
+ } else {
68
+ current!!.lastActivityMs = nowMs
69
+ current!!.eventCount++
70
+ }
71
+ rescheduleTimeoutLocked()
72
+ return current!!.copy()
73
+ }
74
+ }
75
+
76
+ fun endSession() {
77
+ synchronized(lock) {
78
+ endLocked()
79
+ }
80
+ }
81
+
82
+ fun forceStart(): SessionSnapshot {
83
+ synchronized(lock) { endLocked() }
84
+ return recordActivity()
85
+ }
86
+
87
+ fun getCurrent(): SessionSnapshot? = synchronized(lock) { current?.copy() }
88
+
89
+ private fun rescheduleTimeoutLocked() {
90
+ cancelToken?.let { canceller.cancel(it) }
91
+ cancelToken = scheduler.schedule(timeoutMs) {
92
+ synchronized(lock) { endLocked() }
93
+ }
94
+ }
95
+
96
+ private fun endLocked() {
97
+ val snapshot = current ?: return
98
+ cancelToken?.let { canceller.cancel(it) }
99
+ cancelToken = null
100
+
101
+ val nowMs = now()
102
+ snapshot.endedAtMs = nowMs
103
+ snapshot.durationSeconds = maxOf(0L, (nowMs - snapshot.startedAtMs) / 1000L)
104
+
105
+ onEnd?.invoke(snapshot.copy())
106
+ current = null
107
+ }
108
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Batch sender with exponential-backoff retries.
3
+ *
4
+ * Drains the offline queue by sending events in batches to `/api/events`.
5
+ * Retries on 5xx and network errors with exponential backoff (1s, 2s, 4s).
6
+ * Gives up after 3 attempts per batch — events remain in the queue for a
7
+ * future flush attempt but are marked with incremented `attempts` so old
8
+ * failing events don't block new ones indefinitely.
9
+ *
10
+ * The fetch function is injectable for testing and to allow future runtimes
11
+ * to swap in native HTTP clients (e.g. on restricted networks).
12
+ */
13
+ import type { OfflineQueue } from "./offline-queue";
14
+ export interface BatchSenderOptions {
15
+ /** API base URL. Default `https://api.apex.inc`. */
16
+ apiUrl?: string;
17
+ /** Project key attached to each request. Required. */
18
+ projectKey: string;
19
+ /** Max events sent per batch. Default 50. */
20
+ batchSize?: number;
21
+ /** Max retry attempts before giving up on a batch. Default 3. */
22
+ maxRetries?: number;
23
+ /** Base backoff delay in milliseconds. Default 1000. */
24
+ baseBackoffMs?: number;
25
+ /** Injectable fetch (Node, test mocks, custom runtimes). */
26
+ fetchFn?: typeof fetch;
27
+ /** Injectable sleep for testing (must return a Promise that resolves). */
28
+ sleepFn?: (ms: number) => Promise<void>;
29
+ /** Platform header to include (`ios` / `android`). */
30
+ platformHeader?: "ios" | "android" | "web";
31
+ /** Enable verbose logs. */
32
+ debug?: boolean;
33
+ }
34
+ export interface FlushResult {
35
+ flushed: number;
36
+ remaining: number;
37
+ attemptedBatches: number;
38
+ lastError?: string;
39
+ }
40
+ export declare class BatchSender {
41
+ private readonly apiUrl;
42
+ private readonly projectKey;
43
+ private readonly batchSize;
44
+ private readonly maxRetries;
45
+ private readonly baseBackoffMs;
46
+ private readonly fetchFn;
47
+ private readonly sleepFn;
48
+ private readonly platformHeader?;
49
+ private readonly debug;
50
+ constructor(options: BatchSenderOptions);
51
+ /**
52
+ * Flushes as many events from the queue as possible, one batch at a time,
53
+ * with exponential backoff on retryable failures. Stops early on
54
+ * non-retryable errors (400/401/403) and leaves remaining events queued.
55
+ */
56
+ flush(queue: OfflineQueue): Promise<FlushResult>;
57
+ private sendBatchWithRetry;
58
+ private postBatch;
59
+ }
60
+ //# sourceMappingURL=batch-sender.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch-sender.d.ts","sourceRoot":"","sources":["../src/batch-sender.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAe,MAAM,iBAAiB,CAAC;AAEjE,MAAM,WAAW,kBAAkB;IACjC,oDAAoD;IACpD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sDAAsD;IACtD,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4DAA4D;IAC5D,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB,0EAA0E;IAC1E,OAAO,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,sDAAsD;IACtD,cAAc,CAAC,EAAE,KAAK,GAAG,SAAS,GAAG,KAAK,CAAC;IAC3C,2BAA2B;IAC3B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAe;IACvC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgC;IACxD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAA4B;IAC5D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAU;gBAEpB,OAAO,EAAE,kBAAkB;IAiBvC;;;;OAIG;IACG,KAAK,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;YAgCxC,kBAAkB;YAyClB,SAAS;CAoBxB"}
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ /**
3
+ * Batch sender with exponential-backoff retries.
4
+ *
5
+ * Drains the offline queue by sending events in batches to `/api/events`.
6
+ * Retries on 5xx and network errors with exponential backoff (1s, 2s, 4s).
7
+ * Gives up after 3 attempts per batch — events remain in the queue for a
8
+ * future flush attempt but are marked with incremented `attempts` so old
9
+ * failing events don't block new ones indefinitely.
10
+ *
11
+ * The fetch function is injectable for testing and to allow future runtimes
12
+ * to swap in native HTTP clients (e.g. on restricted networks).
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.BatchSender = void 0;
16
+ class BatchSender {
17
+ constructor(options) {
18
+ this.apiUrl = (options.apiUrl ?? "https://api.apex.inc").replace(/\/+$/, "");
19
+ this.projectKey = options.projectKey;
20
+ this.batchSize = options.batchSize ?? 50;
21
+ this.maxRetries = options.maxRetries ?? 3;
22
+ this.baseBackoffMs = options.baseBackoffMs ?? 1000;
23
+ this.fetchFn = options.fetchFn ?? globalThis.fetch?.bind(globalThis);
24
+ this.sleepFn =
25
+ options.sleepFn ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
26
+ this.platformHeader = options.platformHeader;
27
+ this.debug = options.debug ?? false;
28
+ if (!this.fetchFn) {
29
+ throw new Error("BatchSender: fetch is not available. Provide options.fetchFn.");
30
+ }
31
+ }
32
+ /**
33
+ * Flushes as many events from the queue as possible, one batch at a time,
34
+ * with exponential backoff on retryable failures. Stops early on
35
+ * non-retryable errors (400/401/403) and leaves remaining events queued.
36
+ */
37
+ async flush(queue) {
38
+ let flushed = 0;
39
+ let attemptedBatches = 0;
40
+ let lastError;
41
+ while (true) {
42
+ const batch = await queue.peek(this.batchSize);
43
+ if (batch.length === 0)
44
+ break;
45
+ attemptedBatches += 1;
46
+ const outcome = await this.sendBatchWithRetry(batch);
47
+ if (outcome.status === "success") {
48
+ const eventIds = batch.map((q) => q.event.id).filter((id) => !!id);
49
+ await queue.markSent(eventIds);
50
+ flushed += batch.length;
51
+ }
52
+ else if (outcome.status === "retryable_exhausted") {
53
+ const eventIds = batch.map((q) => q.event.id).filter((id) => !!id);
54
+ await queue.markFailed(eventIds);
55
+ lastError = outcome.error;
56
+ break; // leave for next flush cycle
57
+ }
58
+ else {
59
+ // Non-retryable: leave events in place but stop flushing.
60
+ lastError = outcome.error;
61
+ break;
62
+ }
63
+ }
64
+ const remaining = await queue.size();
65
+ return { flushed, remaining, attemptedBatches, lastError };
66
+ }
67
+ async sendBatchWithRetry(batch) {
68
+ let lastError = "unknown";
69
+ for (let attempt = 0; attempt < this.maxRetries; attempt++) {
70
+ try {
71
+ const response = await this.postBatch(batch);
72
+ if (response.ok) {
73
+ return { status: "success" };
74
+ }
75
+ // Non-retryable client errors — leave events, surface to caller.
76
+ if (response.status >= 400 && response.status < 500) {
77
+ return { status: "non_retryable", error: `HTTP ${response.status}` };
78
+ }
79
+ // Retryable: 5xx or unknown. Fall through to backoff.
80
+ lastError = `HTTP ${response.status}`;
81
+ }
82
+ catch (err) {
83
+ lastError = err instanceof Error ? err.message : String(err);
84
+ }
85
+ if (attempt < this.maxRetries - 1) {
86
+ const backoff = this.baseBackoffMs * 2 ** attempt;
87
+ if (this.debug) {
88
+ console.log(`[apex-capacitor] batch-sender retry ${attempt + 1}/${this.maxRetries - 1} in ${backoff}ms (${lastError})`);
89
+ }
90
+ await this.sleepFn(backoff);
91
+ }
92
+ }
93
+ return { status: "retryable_exhausted", error: lastError };
94
+ }
95
+ async postBatch(batch) {
96
+ const body = {
97
+ projectKey: this.projectKey,
98
+ events: batch.map((q) => q.event),
99
+ };
100
+ const headers = {
101
+ "Content-Type": "application/json",
102
+ "X-Apex-Project-Key": this.projectKey,
103
+ };
104
+ if (this.platformHeader) {
105
+ headers["X-Apex-Platform"] = this.platformHeader;
106
+ }
107
+ return this.fetchFn(`${this.apiUrl}/api/events`, {
108
+ method: "POST",
109
+ headers,
110
+ body: JSON.stringify(body),
111
+ });
112
+ }
113
+ }
114
+ exports.BatchSender = BatchSender;
115
+ //# sourceMappingURL=batch-sender.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch-sender.js","sourceRoot":"","sources":["../src/batch-sender.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;GAWG;;;AAgCH,MAAa,WAAW;IAWtB,YAAY,OAA2B;QACrC,IAAI,CAAC,MAAM,GAAG,CAAC,OAAO,CAAC,MAAM,IAAI,sBAAsB,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QAC7E,IAAI,CAAC,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;QACrC,IAAI,CAAC,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,EAAE,CAAC;QACzC,IAAI,CAAC,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,CAAC,CAAC;QAC1C,IAAI,CAAC,aAAa,GAAG,OAAO,CAAC,aAAa,IAAI,IAAI,CAAC;QACnD,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,IAAK,UAAU,CAAC,KAAK,EAAE,IAAI,CAAC,UAAU,CAAkB,CAAC;QACvF,IAAI,CAAC,OAAO;YACV,OAAO,CAAC,OAAO,IAAI,CAAC,CAAC,EAAU,EAAE,EAAE,CAAC,IAAI,OAAO,CAAO,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;QACnF,IAAI,CAAC,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;QAC7C,IAAI,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,KAAK,CAAC;QAEpC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;YAClB,MAAM,IAAI,KAAK,CAAC,+DAA+D,CAAC,CAAC;QACnF,CAAC;IACH,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,KAAK,CAAC,KAAmB;QAC7B,IAAI,OAAO,GAAG,CAAC,CAAC;QAChB,IAAI,gBAAgB,GAAG,CAAC,CAAC;QACzB,IAAI,SAA6B,CAAC;QAElC,OAAO,IAAI,EAAE,CAAC;YACZ,MAAM,KAAK,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YAC/C,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;gBAAE,MAAM;YAC9B,gBAAgB,IAAI,CAAC,CAAC;YAEtB,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,KAAK,CAAC,CAAC;YAErD,IAAI,OAAO,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;gBACjC,MAAM,QAAQ,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,EAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;gBACjF,MAAM,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;gBAC/B,OAAO,IAAI,KAAK,CAAC,MAAM,CAAC;YAC1B,CAAC;iBAAM,IAAI,OAAO,CAAC,MAAM,KAAK,qBAAqB,EAAE,CAAC;gBACpD,MAAM,QAAQ,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,EAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;gBACjF,MAAM,KAAK,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;gBACjC,SAAS,GAAG,OAAO,CAAC,KAAK,CAAC;gBAC1B,MAAM,CAAC,6BAA6B;YACtC,CAAC;iBAAM,CAAC;gBACN,0DAA0D;gBAC1D,SAAS,GAAG,OAAO,CAAC,KAAK,CAAC;gBAC1B,MAAM;YACR,CAAC;QACH,CAAC;QAED,MAAM,SAAS,GAAG,MAAM,KAAK,CAAC,IAAI,EAAE,CAAC;QACrC,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,SAAS,EAAE,CAAC;IAC7D,CAAC;IAEO,KAAK,CAAC,kBAAkB,CAC9B,KAAoB;QAMpB,IAAI,SAAS,GAAG,SAAS,CAAC;QAC1B,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC,UAAU,EAAE,OAAO,EAAE,EAAE,CAAC;YAC3D,IAAI,CAAC;gBACH,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;gBAE7C,IAAI,QAAQ,CAAC,EAAE,EAAE,CAAC;oBAChB,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;gBAC/B,CAAC;gBAED,iEAAiE;gBACjE,IAAI,QAAQ,CAAC,MAAM,IAAI,GAAG,IAAI,QAAQ,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC;oBACpD,OAAO,EAAE,MAAM,EAAE,eAAe,EAAE,KAAK,EAAE,QAAQ,QAAQ,CAAC,MAAM,EAAE,EAAE,CAAC;gBACvE,CAAC;gBAED,sDAAsD;gBACtD,SAAS,GAAG,QAAQ,QAAQ,CAAC,MAAM,EAAE,CAAC;YACxC,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,SAAS,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAC/D,CAAC;YAED,IAAI,OAAO,GAAG,IAAI,CAAC,UAAU,GAAG,CAAC,EAAE,CAAC;gBAClC,MAAM,OAAO,GAAG,IAAI,CAAC,aAAa,GAAG,CAAC,IAAI,OAAO,CAAC;gBAClD,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;oBACf,OAAO,CAAC,GAAG,CACT,uCAAuC,OAAO,GAAG,CAAC,IAAI,IAAI,CAAC,UAAU,GAAG,CAAC,OAAO,OAAO,OAAO,SAAS,GAAG,CAC3G,CAAC;gBACJ,CAAC;gBACD,MAAM,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;YAC9B,CAAC;QACH,CAAC;QAED,OAAO,EAAE,MAAM,EAAE,qBAAqB,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;IAC7D,CAAC;IAEO,KAAK,CAAC,SAAS,CAAC,KAAoB;QAC1C,MAAM,IAAI,GAAG;YACX,UAAU,EAAE,IAAI,CAAC,UAAU;YAC3B,MAAM,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC;SAClC,CAAC;QAEF,MAAM,OAAO,GAA2B;YACtC,cAAc,EAAE,kBAAkB;YAClC,oBAAoB,EAAE,IAAI,CAAC,UAAU;SACtC,CAAC;QACF,IAAI,IAAI,CAAC,cAAc,EAAE,CAAC;YACxB,OAAO,CAAC,iBAAiB,CAAC,GAAG,IAAI,CAAC,cAAc,CAAC;QACnD,CAAC;QAED,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,aAAa,EAAE;YAC/C,MAAM,EAAE,MAAM;YACd,OAAO;YACP,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;SAC3B,CAAC,CAAC;IACL,CAAC;CACF;AA9HD,kCA8HC"}