@arkadiadev/sdk 0.0.0-stage → 1.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 1.1.0
4
+
5
+ Première version publiée sur npm (la 1.0.0 était un fichier à copier).
6
+
7
+ - Utilise l'API v1 (`/v1/events`) : acceptation partielle, callback `onRejected`.
8
+ - Nouvelles méthodes `status()`, `analytics()`, `shutdown()` et `createArkadiaClient()`.
9
+ - Nouveau point d'entrée `@arkadiadev/sdk/webhooks` (`verifyWebhookSignature`, `constructWebhookEvent`).
10
+ - Corrigé : les envois à la fermeture de page (sendBeacon) étaient refusés faute d'en-tête `Authorization` ; ils passent désormais par `fetch` keepalive et sont rejoués sans doublon s'ils n'aboutissent pas.
11
+ - Corrigé : lots limités à 20 events pour les clés test (au-delà, le serveur rejetait le lot).
12
+ - Corrigé : la clé d'idempotence est conservée d'un essai à l'autre ; `Retry-After` est respecté ; un quota mensuel atteint n'est plus réessayé en boucle.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arkadia Dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,114 @@
1
- # Temporary Holding Version
1
+ # @arkadiadev/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ SDK officiel de l'API **Arkadia** : envoyez l'activité et les ventes de votre application, lisez votre quota et vos statistiques, vérifiez les webhooks.
4
+
5
+ - Aucune dépendance · navigateur, Node.js 18+, React Native
6
+ - File d'attente hors ligne, renvois automatiques sans doublon (clé d'idempotence)
7
+ - Types TypeScript inclus
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install @arkadiadev/sdk
13
+ ```
14
+
15
+ Sans bundler, en `<script>` :
16
+
17
+ ```html
18
+ <script src="https://cdn.jsdelivr.net/npm/@arkadiadev/sdk@1.1.0/dist/arkadia.global.js"></script>
19
+ <script>
20
+ ArkadiaClient.init({ apiKey: 'ark_live_…', appCode: 'APP-XXXX-XXXX' });
21
+ </script>
22
+ ```
23
+
24
+ ## Démarrage
25
+
26
+ ```js
27
+ import { ArkadiaClient } from '@arkadiadev/sdk';
28
+
29
+ await ArkadiaClient.init({
30
+ apiKey: 'ark_live_…', // onglet « API » de votre espace Arkadia
31
+ appCode: 'APP-XXXX-XXXX',
32
+ platform: 'web', // 'web' | 'node' | 'react-native' | …
33
+ appVersion: '2.3.0',
34
+ debug: false,
35
+ });
36
+
37
+ ArkadiaClient.screenView('dashboard', { previousScreen: 'login' });
38
+ ArkadiaClient.featureUsed('export_pdf', { metadata: { format: 'a4' } });
39
+ ArkadiaClient.purchaseCompleted('module-crm', 'Module CRM', 250000, { userId: hashedUserId });
40
+ ```
41
+
42
+ Les events sont regroupés et envoyés toutes les 30 secondes, dès que le lot est plein, à la fermeture de la page, ou quand vous appelez `ArkadiaClient.flush()`. Les crashs partent immédiatement.
43
+
44
+ > Les montants sont en **centimes** de la devise de votre compte (XOF par défaut). Pour `userId`, envoyez un identifiant **anonymisé** (un hash, par exemple).
45
+
46
+ ### Méthodes
47
+
48
+ | Méthode | Event envoyé |
49
+ |---|---|
50
+ | `track(event, { properties, userId, sessionId })` | n'importe quel event du catalogue |
51
+ | `screenView(screen, { previousScreen })` | `app.screen_view` |
52
+ | `sessionStart()` / `sessionEnd(durationSeconds)` | `app.session_start` / `app.session_end` |
53
+ | `featureUsed(name, { metadata })` | `app.feature_used` |
54
+ | `crash(type, message, { stackTrace })` | `app.crash` (envoi immédiat) |
55
+ | `subscriptionStarted(plan, montant, interval)` | `sale.subscription_started` |
56
+ | `subscriptionCancelled(plan, { reason, daysActive })` | `sale.subscription_cancelled` |
57
+ | `purchaseCompleted(productId, productName, montant)` | `sale.purchase_completed` |
58
+ | `trialStarted(plan, jours)` / `trialConverted(plan, montant)` | `sale.trial_started` / `sale.trial_converted` |
59
+
60
+ ### Lire son quota et ses statistiques
61
+
62
+ ```js
63
+ const { quota } = await ArkadiaClient.status();
64
+ console.log(`${quota.used_this_month} / ${quota.limit} events ce mois`);
65
+
66
+ const { days } = await ArkadiaClient.analytics({ from: '2026-09-01', to: '2026-09-30' });
67
+ ```
68
+
69
+ Ces deux méthodes lèvent une `ArkadiaApiError` (`status`, `code`, `body`) en cas d'erreur.
70
+
71
+ ### Node.js (scripts, serveurs)
72
+
73
+ Appelez `await ArkadiaClient.shutdown()` avant la fin du processus pour envoyer les derniers events. Pour envoyer au nom de plusieurs apps, créez un client par app avec `createArkadiaClient()`.
74
+
75
+ ## Webhooks
76
+
77
+ Arkadia peut appeler votre serveur à chaque vente, crash ou alerte de quota (configuration dans l'onglet « API » de votre espace). Chaque requête est signée : **vérifiez-la toujours** sur le corps brut.
78
+
79
+ ```js
80
+ import express from 'express';
81
+ import { constructWebhookEvent } from '@arkadiadev/sdk/webhooks';
82
+
83
+ const app = express();
84
+
85
+ app.post('/webhooks/arkadia', express.raw({ type: 'application/json' }), (req, res) => {
86
+ let event;
87
+ try {
88
+ event = constructWebhookEvent(req.body, req.get('X-Arkadia-Signature'), process.env.ARKADIA_WEBHOOK_SECRET);
89
+ } catch {
90
+ return res.sendStatus(400);
91
+ }
92
+
93
+ if (event.type === 'events') {
94
+ for (const ev of event.data.events) {
95
+ // ev.event === 'sale.purchase_completed', ev.properties.amount, …
96
+ }
97
+ }
98
+ res.sendStatus(200); // répondez vite (< 10 s) ; traitez en arrière-plan si besoin
99
+ });
100
+ ```
101
+
102
+ - Répondez par un code **2xx** ; sinon Arkadia réessaie (1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h).
103
+ - Une même livraison peut arriver deux fois : dédupliquez sur `event.id` (aussi dans l'en-tête `X-Arkadia-Delivery`).
104
+ - Types : `events`, `quota.warning`, `quota.exceeded`, `webhook.test`.
105
+
106
+ ## Limites
107
+
108
+ | | Clé live | Clé test |
109
+ |---|---|---|
110
+ | Events par requête | 50 | 20 |
111
+ | Events par minute | 300 | 60 |
112
+ | Lectures (`status`, `analytics`) par minute | 60 | 30 |
113
+
114
+ Le SDK respecte automatiquement ces limites et l'en-tête `Retry-After`.
@@ -0,0 +1,2 @@
1
+ var ArkadiaSDK=(()=>{var T=Object.defineProperty;var V=Object.getOwnPropertyDescriptor;var K=Object.getOwnPropertyNames;var L=Object.prototype.hasOwnProperty;var D=(i,a)=>{for(var s in a)T(i,s,{get:a[s],enumerable:!0})},q=(i,a,s,c)=>{if(a&&typeof a=="object"||typeof a=="function")for(let o of K(a))!L.call(i,o)&&o!==s&&T(i,o,{get:()=>a[o],enumerable:!(c=V(a,o))||c.enumerable});return i};var P=i=>q(T({},"__esModule",{value:!0}),i);var B={};D(B,{ArkadiaApiError:()=>y,ArkadiaClient:()=>j,DEFAULT_BASE_URL:()=>U,SDK_VERSION:()=>$,createArkadiaClient:()=>x,default:()=>J});var $="1.1.0",U="https://europe-west1-arkadiadev.cloudfunctions.net/arkadiaApi",S="arkadia_offline_queue";var y=class extends Error{constructor(a,s){super(s?.message||`Erreur API Arkadia (${a})`),this.name="ArkadiaApiError",this.status=a,this.code=s?.error??null,this.body=s}};function Q(){let i={},a={get:s=>i[s]??null,set:(s,c)=>{i[s]=c},remove:s=>{delete i[s]}};try{let s=globalThis.localStorage;return s?(s.setItem("__arkadia_test__","1"),s.removeItem("__arkadia_test__"),{get:c=>{try{return JSON.parse(s.getItem(c))}catch{return null}},set:(c,o)=>{try{s.setItem(c,JSON.stringify(o))}catch{}},remove:c=>{try{s.removeItem(c)}catch{}}}):a}catch{return a}}function A(){let i=globalThis.crypto;if(i?.randomUUID)return i.randomUUID();let a=new Uint8Array(16);if(i?.getRandomValues)i.getRandomValues(a);else for(let c=0;c<16;c++)a[c]=Math.floor(Math.random()*256);a[6]=a[6]&15|64,a[8]=a[8]&63|128;let s=[...a].map(c=>c.toString(16).padStart(2,"0")).join("");return`${s.slice(0,8)}-${s.slice(8,12)}-${s.slice(12,16)}-${s.slice(16,20)}-${s.slice(20)}`}var d=i=>Object.fromEntries(Object.entries(i).filter(([,a])=>a!=null));function x(i={}){let a=i.fetch??((...e)=>globalThis.fetch(...e)),s=i.sleep??(e=>new Promise(n=>setTimeout(n,e))),c=i.now??(()=>new Date),o=null,g=i.storage??null,f=null,I=!1,m=[],p=(...e)=>{o?.debug&&console.log("[Arkadia]",...e)},_=()=>o.apiKey.startsWith("ark_test_")?20:50,v=e=>`${o.baseUrl.replace(/\/+$/,"")}/v1${e}`,b=(e={})=>({Authorization:`Bearer ${o.apiKey}`,"X-Arkadia-Version":"1",...e});function w(){let e=g.get(S);return Array.isArray(e)?e.length&&!e[0]?.events?[{id:A(),events:e}]:e:[]}function M(e,n){let t=w().filter(l=>l.id!==n);t.push({id:n,events:e});let r=t.reduce((l,h)=>l+h.events.length,0);for(;r>500&&t.length>1;)r-=t.shift().events.length;g.set(S,t),p(`${e.length} events gard\xE9s hors ligne (total : ${r})`)}function R(e){let n=w().filter(t=>t.id!==e);n.length?g.set(S,n):g.remove(S)}function N(e,n){let t=Number(e.headers?.get?.("retry-after"));return Number.isFinite(t)&&t>0?Math.min(t*1e3,6e4):n}async function k(e,n=A()){for(let t=0;t<=3;t++){let r;try{r=await a(v("/events"),{method:"POST",headers:b({"Content-Type":"application/json","X-Idempotency-Key":n}),body:JSON.stringify({appCode:o.appCode,batch:e}),signal:AbortSignal.timeout?.(15e3)})}catch(h){p(`Erreur r\xE9seau (tentative ${t+1}) :`,h?.message??h),t<3&&await s(2**t*1e3);continue}let l=await r.json().catch(()=>null);if(r.ok)return p(`Lot accept\xE9 \u2014 ${l?.ingested??e.length} events`),l?.rejected?.length&&(p("Events rejet\xE9s :",l.rejected),o.onRejected?.(l.rejected,e)),"sent";if(r.status===429&&l?.error==="quota_exceeded")return p("Quota mensuel atteint \u2014 lot abandonn\xE9"),"dropped";if(r.status===429||r.status===409){let h=N(r,5e3);p(`${r.status} \u2014 nouvel essai dans ${Math.round(h/1e3)} s`),t<3&&await s(h);continue}if(r.status>=400&&r.status<500)return p(`Erreur ${r.status} (${l?.error}) \u2014 lot abandonn\xE9 :`,l?.message),"dropped";p(`Erreur serveur ${r.status} (tentative ${t+1})`),t<3&&await s(2**t*1e3)}return M(e,n),"offline"}async function E(){for(;m.length;)await k(m.splice(0,_()))}async function X(){let e=w();if(e.length){p(`Renvoi de ${e.length} lot(s) hors ligne`);for(let{id:n,events:t}of e){R(n);for(let r=0;r<t.length;r+=_()){let l=t.slice(r,r+_());await k(l,t.length<=_()?n:`${n}-${r}`)}}}}function O(){if(!m.length)return;let e=m.splice(0,_()),n=A();M(e,n);try{a(v("/events"),{method:"POST",keepalive:!0,headers:b({"Content-Type":"application/json","X-Idempotency-Key":n}),body:JSON.stringify({appCode:o.appCode,batch:e})}).then(t=>{t.ok&&R(n)},()=>{})}catch{}}async function F(e){if(!e?.apiKey||!e?.appCode)throw new Error("ArkadiaClient.init : apiKey et appCode sont requis.");o={platform:"web",appVersion:"1.0.0",debug:!1,baseUrl:U,flushIntervalMs:3e4,...e},g=g??Q(),f&&clearInterval(f),f=setInterval(()=>{E().catch(()=>{})},o.flushIntervalMs),f.unref?.();let n=globalThis.document;!I&&globalThis.addEventListener&&n&&(globalThis.addEventListener("pagehide",O),n.addEventListener("visibilitychange",()=>{n.visibilityState==="hidden"&&O()}),I=!0),p(`SDK ${$} initialis\xE9 \u2014 appCode ${o.appCode}`),await X()}function u(e,{properties:n,sessionId:t,userId:r}={}){if(!o){console.warn(`[Arkadia] SDK non initialis\xE9 \u2014 event ignor\xE9 : ${e}`);return}m.push(d({event:e,timestamp:c().toISOString(),properties:n??{},context:{platform:o.platform,appVersion:o.appVersion,sdkVersion:$},sessionId:t,userId:r})),m.length>=_()&&E().catch(()=>{})}async function C(e){if(!o)throw new Error("ArkadiaClient non initialis\xE9.");let n=await a(v(e),{headers:b(),signal:AbortSignal.timeout?.(15e3)}),t=await n.json().catch(()=>null);if(!n.ok)throw new y(n.status,t);return t}return{init:F,track:u,flush:()=>E(),async shutdown(){f&&clearInterval(f),f=null,await E()},status:()=>C("/status"),analytics:({from:e,to:n}={})=>{let t=new URLSearchParams(d({from:e,to:n})).toString();return C(`/analytics${t?`?${t}`:""}`)},screenView(e,{previousScreen:n,userId:t,sessionId:r}={}){u("app.screen_view",{properties:d({screen:e,previousScreen:n}),sessionId:r,userId:t})},sessionStart({userId:e,sessionId:n}={}){u("app.session_start",{sessionId:n,userId:e})},sessionEnd(e,{userId:n,sessionId:t}={}){u("app.session_end",{properties:{durationSeconds:e},sessionId:t,userId:n})},featureUsed(e,{metadata:n,userId:t,sessionId:r}={}){u("app.feature_used",{properties:d({featureName:e,metadata:n}),sessionId:r,userId:t})},crash(e,n,{stackTrace:t,userId:r,sessionId:l}={}){u("app.crash",{properties:d({errorType:e,errorMessage:String(n).slice(0,500),stackTrace:t!=null?String(t).slice(0,2e3):null}),sessionId:l,userId:r}),E().catch(()=>{})},subscriptionStarted(e,n,t,{userId:r,couponCode:l}={}){u("sale.subscription_started",{properties:d({plan:e,amount:n,interval:t,couponCode:l}),userId:r})},subscriptionCancelled(e,{reason:n,daysActive:t,userId:r}={}){u("sale.subscription_cancelled",{properties:d({plan:e,reason:n,daysActive:t}),userId:r})},purchaseCompleted(e,n,t,{userId:r,couponCode:l}={}){u("sale.purchase_completed",{properties:d({productId:e,productName:n,amount:t,couponCode:l}),userId:r})},trialStarted(e,n,{userId:t}={}){u("sale.trial_started",{properties:{plan:e,trialDays:n},userId:t})},trialConverted(e,n,{userId:t}={}){u("sale.trial_converted",{properties:{plan:e,amount:n},userId:t})}}}var j=x(),J=j;return P(B);})();
2
+ window.ArkadiaClient=ArkadiaSDK.ArkadiaClient;
package/dist/index.cjs ADDED
@@ -0,0 +1,336 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __export = (target, all) => {
6
+ for (var name in all)
7
+ __defProp(target, name, { get: all[name], enumerable: true });
8
+ };
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
+
19
+ // src/index.js
20
+ var index_exports = {};
21
+ __export(index_exports, {
22
+ ArkadiaApiError: () => ArkadiaApiError,
23
+ ArkadiaClient: () => ArkadiaClient,
24
+ DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
25
+ SDK_VERSION: () => SDK_VERSION,
26
+ createArkadiaClient: () => createArkadiaClient,
27
+ default: () => index_default
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+ var SDK_VERSION = "1.1.0";
31
+ var DEFAULT_BASE_URL = "https://europe-west1-arkadiadev.cloudfunctions.net/arkadiaApi";
32
+ var OFFLINE_KEY = "arkadia_offline_queue";
33
+ var MAX_OFFLINE_EVENTS = 500;
34
+ var MAX_RETRIES = 3;
35
+ var MAX_RETRY_AFTER_MS = 6e4;
36
+ var REQUEST_TIMEOUT_MS = 15e3;
37
+ var ArkadiaApiError = class extends Error {
38
+ constructor(status, body) {
39
+ super(body?.message || `Erreur API Arkadia (${status})`);
40
+ this.name = "ArkadiaApiError";
41
+ this.status = status;
42
+ this.code = body?.error ?? null;
43
+ this.body = body;
44
+ }
45
+ };
46
+ function defaultStorage() {
47
+ const mem = {};
48
+ const memory = {
49
+ get: (k) => mem[k] ?? null,
50
+ set: (k, v) => {
51
+ mem[k] = v;
52
+ },
53
+ remove: (k) => {
54
+ delete mem[k];
55
+ }
56
+ };
57
+ try {
58
+ const ls = globalThis.localStorage;
59
+ if (!ls) return memory;
60
+ ls.setItem("__arkadia_test__", "1");
61
+ ls.removeItem("__arkadia_test__");
62
+ return {
63
+ get: (k) => {
64
+ try {
65
+ return JSON.parse(ls.getItem(k));
66
+ } catch {
67
+ return null;
68
+ }
69
+ },
70
+ set: (k, v) => {
71
+ try {
72
+ ls.setItem(k, JSON.stringify(v));
73
+ } catch {
74
+ }
75
+ },
76
+ remove: (k) => {
77
+ try {
78
+ ls.removeItem(k);
79
+ } catch {
80
+ }
81
+ }
82
+ };
83
+ } catch {
84
+ return memory;
85
+ }
86
+ }
87
+ function uuid() {
88
+ const c = globalThis.crypto;
89
+ if (c?.randomUUID) return c.randomUUID();
90
+ const bytes = new Uint8Array(16);
91
+ if (c?.getRandomValues) c.getRandomValues(bytes);
92
+ else for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
93
+ bytes[6] = bytes[6] & 15 | 64;
94
+ bytes[8] = bytes[8] & 63 | 128;
95
+ const h = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
96
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
97
+ }
98
+ var pick = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
99
+ function createArkadiaClient(deps = {}) {
100
+ const fetchFn = deps.fetch ?? ((...args) => globalThis.fetch(...args));
101
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
102
+ const now = deps.now ?? (() => /* @__PURE__ */ new Date());
103
+ let config = null;
104
+ let storage = deps.storage ?? null;
105
+ let timer = null;
106
+ let listenersAttached = false;
107
+ const queue = [];
108
+ const log = (...args) => {
109
+ if (config?.debug) console.log("[Arkadia]", ...args);
110
+ };
111
+ const batchMax = () => config.apiKey.startsWith("ark_test_") ? 20 : 50;
112
+ const url = (path) => `${config.baseUrl.replace(/\/+$/, "")}/v1${path}`;
113
+ const headers = (extra = {}) => ({
114
+ Authorization: `Bearer ${config.apiKey}`,
115
+ "X-Arkadia-Version": "1",
116
+ ...extra
117
+ });
118
+ function loadOffline() {
119
+ const stored = storage.get(OFFLINE_KEY);
120
+ if (!Array.isArray(stored)) return [];
121
+ if (stored.length && !stored[0]?.events) return [{ id: uuid(), events: stored }];
122
+ return stored;
123
+ }
124
+ function saveOffline(events, id) {
125
+ const batches = loadOffline().filter((b) => b.id !== id);
126
+ batches.push({ id, events });
127
+ let total = batches.reduce((n, b) => n + b.events.length, 0);
128
+ while (total > MAX_OFFLINE_EVENTS && batches.length > 1) total -= batches.shift().events.length;
129
+ storage.set(OFFLINE_KEY, batches);
130
+ log(`${events.length} events gard\xE9s hors ligne (total : ${total})`);
131
+ }
132
+ function removeOffline(id) {
133
+ const batches = loadOffline().filter((b) => b.id !== id);
134
+ if (batches.length) storage.set(OFFLINE_KEY, batches);
135
+ else storage.remove(OFFLINE_KEY);
136
+ }
137
+ function retryAfterMs(res, fallback) {
138
+ const seconds = Number(res.headers?.get?.("retry-after"));
139
+ return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds * 1e3, MAX_RETRY_AFTER_MS) : fallback;
140
+ }
141
+ async function sendBatch(events, batchId = uuid()) {
142
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
143
+ let res;
144
+ try {
145
+ res = await fetchFn(url("/events"), {
146
+ method: "POST",
147
+ headers: headers({ "Content-Type": "application/json", "X-Idempotency-Key": batchId }),
148
+ body: JSON.stringify({ appCode: config.appCode, batch: events }),
149
+ signal: AbortSignal.timeout?.(REQUEST_TIMEOUT_MS)
150
+ });
151
+ } catch (err) {
152
+ log(`Erreur r\xE9seau (tentative ${attempt + 1}) :`, err?.message ?? err);
153
+ if (attempt < MAX_RETRIES) await sleep(2 ** attempt * 1e3);
154
+ continue;
155
+ }
156
+ const body = await res.json().catch(() => null);
157
+ if (res.ok) {
158
+ log(`Lot accept\xE9 \u2014 ${body?.ingested ?? events.length} events`);
159
+ if (body?.rejected?.length) {
160
+ log("Events rejet\xE9s :", body.rejected);
161
+ config.onRejected?.(body.rejected, events);
162
+ }
163
+ return "sent";
164
+ }
165
+ if (res.status === 429 && body?.error === "quota_exceeded") {
166
+ log("Quota mensuel atteint \u2014 lot abandonn\xE9");
167
+ return "dropped";
168
+ }
169
+ if (res.status === 429 || res.status === 409) {
170
+ const wait = retryAfterMs(res, 5e3);
171
+ log(`${res.status} \u2014 nouvel essai dans ${Math.round(wait / 1e3)} s`);
172
+ if (attempt < MAX_RETRIES) await sleep(wait);
173
+ continue;
174
+ }
175
+ if (res.status >= 400 && res.status < 500) {
176
+ log(`Erreur ${res.status} (${body?.error}) \u2014 lot abandonn\xE9 :`, body?.message);
177
+ return "dropped";
178
+ }
179
+ log(`Erreur serveur ${res.status} (tentative ${attempt + 1})`);
180
+ if (attempt < MAX_RETRIES) await sleep(2 ** attempt * 1e3);
181
+ }
182
+ saveOffline(events, batchId);
183
+ return "offline";
184
+ }
185
+ async function flushQueue() {
186
+ while (queue.length) {
187
+ await sendBatch(queue.splice(0, batchMax()));
188
+ }
189
+ }
190
+ async function flushOffline() {
191
+ const batches = loadOffline();
192
+ if (!batches.length) return;
193
+ log(`Renvoi de ${batches.length} lot(s) hors ligne`);
194
+ for (const { id, events } of batches) {
195
+ removeOffline(id);
196
+ for (let i = 0; i < events.length; i += batchMax()) {
197
+ const slice = events.slice(i, i + batchMax());
198
+ await sendBatch(slice, events.length <= batchMax() ? id : `${id}-${i}`);
199
+ }
200
+ }
201
+ }
202
+ function flushOnExit() {
203
+ if (!queue.length) return;
204
+ const events = queue.splice(0, batchMax());
205
+ const id = uuid();
206
+ saveOffline(events, id);
207
+ try {
208
+ fetchFn(url("/events"), {
209
+ method: "POST",
210
+ keepalive: true,
211
+ headers: headers({ "Content-Type": "application/json", "X-Idempotency-Key": id }),
212
+ body: JSON.stringify({ appCode: config.appCode, batch: events })
213
+ }).then((res) => {
214
+ if (res.ok) removeOffline(id);
215
+ }, () => {
216
+ });
217
+ } catch {
218
+ }
219
+ }
220
+ async function init(options) {
221
+ if (!options?.apiKey || !options?.appCode) {
222
+ throw new Error("ArkadiaClient.init : apiKey et appCode sont requis.");
223
+ }
224
+ config = {
225
+ platform: "web",
226
+ appVersion: "1.0.0",
227
+ debug: false,
228
+ baseUrl: DEFAULT_BASE_URL,
229
+ flushIntervalMs: 3e4,
230
+ ...options
231
+ };
232
+ storage = storage ?? defaultStorage();
233
+ if (timer) clearInterval(timer);
234
+ timer = setInterval(() => {
235
+ flushQueue().catch(() => {
236
+ });
237
+ }, config.flushIntervalMs);
238
+ timer.unref?.();
239
+ const doc = globalThis.document;
240
+ if (!listenersAttached && globalThis.addEventListener && doc) {
241
+ globalThis.addEventListener("pagehide", flushOnExit);
242
+ doc.addEventListener("visibilitychange", () => {
243
+ if (doc.visibilityState === "hidden") flushOnExit();
244
+ });
245
+ listenersAttached = true;
246
+ }
247
+ log(`SDK ${SDK_VERSION} initialis\xE9 \u2014 appCode ${config.appCode}`);
248
+ await flushOffline();
249
+ }
250
+ function track(event, { properties, sessionId, userId } = {}) {
251
+ if (!config) {
252
+ console.warn(`[Arkadia] SDK non initialis\xE9 \u2014 event ignor\xE9 : ${event}`);
253
+ return;
254
+ }
255
+ queue.push(pick({
256
+ event,
257
+ timestamp: now().toISOString(),
258
+ properties: properties ?? {},
259
+ context: { platform: config.platform, appVersion: config.appVersion, sdkVersion: SDK_VERSION },
260
+ sessionId,
261
+ userId
262
+ }));
263
+ if (queue.length >= batchMax()) flushQueue().catch(() => {
264
+ });
265
+ }
266
+ async function request(path) {
267
+ if (!config) throw new Error("ArkadiaClient non initialis\xE9.");
268
+ const res = await fetchFn(url(path), { headers: headers(), signal: AbortSignal.timeout?.(REQUEST_TIMEOUT_MS) });
269
+ const body = await res.json().catch(() => null);
270
+ if (!res.ok) throw new ArkadiaApiError(res.status, body);
271
+ return body;
272
+ }
273
+ return {
274
+ init,
275
+ track,
276
+ /** Envoie immédiatement tout ce qui est en file. */
277
+ flush: () => flushQueue(),
278
+ /** Arrête le minuteur et envoie ce qui reste (Node, tests, fin de script). */
279
+ async shutdown() {
280
+ if (timer) clearInterval(timer);
281
+ timer = null;
282
+ await flushQueue();
283
+ },
284
+ /** Quota, limites et informations sur la clé. */
285
+ status: () => request("/status"),
286
+ /** Agrégats quotidiens : { from?: 'YYYY-MM-DD', to?: 'YYYY-MM-DD' }. */
287
+ analytics: ({ from, to } = {}) => {
288
+ const qs = new URLSearchParams(pick({ from, to })).toString();
289
+ return request(`/analytics${qs ? `?${qs}` : ""}`);
290
+ },
291
+ // ── Raccourcis ────────────────────────────────────────────────────────────
292
+ screenView(screen, { previousScreen, userId, sessionId } = {}) {
293
+ track("app.screen_view", { properties: pick({ screen, previousScreen }), sessionId, userId });
294
+ },
295
+ sessionStart({ userId, sessionId } = {}) {
296
+ track("app.session_start", { sessionId, userId });
297
+ },
298
+ sessionEnd(durationSeconds, { userId, sessionId } = {}) {
299
+ track("app.session_end", { properties: { durationSeconds }, sessionId, userId });
300
+ },
301
+ featureUsed(featureName, { metadata, userId, sessionId } = {}) {
302
+ track("app.feature_used", { properties: pick({ featureName, metadata }), sessionId, userId });
303
+ },
304
+ /** Crash : envoyé immédiatement. */
305
+ crash(errorType, errorMessage, { stackTrace, userId, sessionId } = {}) {
306
+ track("app.crash", {
307
+ properties: pick({
308
+ errorType,
309
+ errorMessage: String(errorMessage).slice(0, 500),
310
+ stackTrace: stackTrace != null ? String(stackTrace).slice(0, 2e3) : null
311
+ }),
312
+ sessionId,
313
+ userId
314
+ });
315
+ flushQueue().catch(() => {
316
+ });
317
+ },
318
+ subscriptionStarted(plan, amountCentimes, interval, { userId, couponCode } = {}) {
319
+ track("sale.subscription_started", { properties: pick({ plan, amount: amountCentimes, interval, couponCode }), userId });
320
+ },
321
+ subscriptionCancelled(plan, { reason, daysActive, userId } = {}) {
322
+ track("sale.subscription_cancelled", { properties: pick({ plan, reason, daysActive }), userId });
323
+ },
324
+ purchaseCompleted(productId, productName, amountCentimes, { userId, couponCode } = {}) {
325
+ track("sale.purchase_completed", { properties: pick({ productId, productName, amount: amountCentimes, couponCode }), userId });
326
+ },
327
+ trialStarted(plan, trialDays, { userId } = {}) {
328
+ track("sale.trial_started", { properties: { plan, trialDays }, userId });
329
+ },
330
+ trialConverted(plan, amountCentimes, { userId } = {}) {
331
+ track("sale.trial_converted", { properties: { plan, amount: amountCentimes }, userId });
332
+ }
333
+ };
334
+ }
335
+ var ArkadiaClient = createArkadiaClient();
336
+ var index_default = ArkadiaClient;
@@ -0,0 +1,126 @@
1
+ export declare const SDK_VERSION: string;
2
+ export declare const DEFAULT_BASE_URL: string;
3
+
4
+ export type ArkadiaEventType =
5
+ | 'app.install' | 'app.update' | 'app.session_start' | 'app.session_end'
6
+ | 'app.screen_view' | 'app.feature_used' | 'app.crash'
7
+ | 'app.onboarding_step' | 'app.onboarding_completed' | 'app.search'
8
+ | 'sale.subscription_started' | 'sale.subscription_renewed'
9
+ | 'sale.subscription_cancelled' | 'sale.subscription_paused'
10
+ | 'sale.subscription_resumed' | 'sale.plan_upgraded' | 'sale.plan_downgraded'
11
+ | 'sale.purchase_completed' | 'sale.module_activated' | 'sale.module_deactivated'
12
+ | 'sale.trial_started' | 'sale.trial_converted' | 'sale.trial_expired'
13
+ | 'sale.refund_issued' | 'sale.coupon_used';
14
+
15
+ export interface RejectedEvent {
16
+ index: number;
17
+ error: 'invalid_event_type' | 'invalid_event';
18
+ message: string;
19
+ }
20
+
21
+ export interface ArkadiaInitOptions {
22
+ /** Clé API : ark_live_… ou ark_test_… */
23
+ apiKey: string;
24
+ /** Code de l'application dans Arkadia. */
25
+ appCode: string;
26
+ /** 'web' | 'node' | 'react-native' | 'android' | 'ios'… (défaut : 'web') */
27
+ platform?: string;
28
+ /** Version de votre app (défaut : '1.0.0'). */
29
+ appVersion?: string;
30
+ /** Logs console si true. */
31
+ debug?: boolean;
32
+ /** URL de base de l'API (défaut : production Arkadia). */
33
+ baseUrl?: string;
34
+ /** Intervalle d'envoi automatique en ms (défaut : 30 000). */
35
+ flushIntervalMs?: number;
36
+ /** Appelé quand le serveur écarte des events invalides d'un lot. */
37
+ onRejected?: (rejected: RejectedEvent[], batch: object[]) => void;
38
+ }
39
+
40
+ export interface TrackOptions {
41
+ properties?: Record<string, unknown>;
42
+ sessionId?: string;
43
+ userId?: string;
44
+ }
45
+
46
+ export interface ArkadiaStatus {
47
+ appCode: string;
48
+ quota: {
49
+ limit: number;
50
+ used_this_month: number;
51
+ remaining: number;
52
+ reset_at: string | null;
53
+ percent_used: number;
54
+ is_unlimited: boolean;
55
+ };
56
+ rate_limit: { limit_per_minute: number; batch_max: number; reads_per_minute: number };
57
+ key: { type: 'live' | 'test'; label: string; expires_at: string | null };
58
+ }
59
+
60
+ export interface ArkadiaDailyAnalytics {
61
+ date: string;
62
+ appCode: string;
63
+ sessions: number;
64
+ uniqueUsers: number;
65
+ avgSessionDuration: number;
66
+ screenViews: number;
67
+ crashes: number;
68
+ installs: number;
69
+ updates: number;
70
+ revenue: number;
71
+ currency: string;
72
+ newSubscriptions: number;
73
+ renewals: number;
74
+ cancellations: number;
75
+ oneTimePurchases: number;
76
+ activeModules: number;
77
+ trialStarts: number;
78
+ trialConversions: number;
79
+ topScreens: { screen: string; views: number }[];
80
+ topFeatures: { feature: string; uses: number }[];
81
+ eventCount: number;
82
+ updatedAt: string | null;
83
+ }
84
+
85
+ export interface ArkadiaAnalytics {
86
+ appCode: string;
87
+ from: string;
88
+ to: string;
89
+ days: ArkadiaDailyAnalytics[];
90
+ }
91
+
92
+ export declare class ArkadiaApiError extends Error {
93
+ readonly status: number;
94
+ readonly code: string | null;
95
+ readonly body: unknown;
96
+ }
97
+
98
+ export interface ArkadiaClientInstance {
99
+ init(options: ArkadiaInitOptions): Promise<void>;
100
+ track(event: ArkadiaEventType | (string & {}), options?: TrackOptions): void;
101
+ flush(): Promise<void>;
102
+ shutdown(): Promise<void>;
103
+ status(): Promise<ArkadiaStatus>;
104
+ analytics(range?: { from?: string; to?: string }): Promise<ArkadiaAnalytics>;
105
+
106
+ screenView(screen: string, options?: { previousScreen?: string; userId?: string; sessionId?: string }): void;
107
+ sessionStart(options?: { userId?: string; sessionId?: string }): void;
108
+ sessionEnd(durationSeconds: number, options?: { userId?: string; sessionId?: string }): void;
109
+ featureUsed(featureName: string, options?: { metadata?: Record<string, unknown>; userId?: string; sessionId?: string }): void;
110
+ crash(errorType: string, errorMessage: unknown, options?: { stackTrace?: unknown; userId?: string; sessionId?: string }): void;
111
+ subscriptionStarted(plan: string, amountCentimes: number, interval: string, options?: { userId?: string; couponCode?: string }): void;
112
+ subscriptionCancelled(plan: string, options?: { reason?: string; daysActive?: number; userId?: string }): void;
113
+ purchaseCompleted(productId: string, productName: string, amountCentimes: number, options?: { userId?: string; couponCode?: string }): void;
114
+ trialStarted(plan: string, trialDays: number, options?: { userId?: string }): void;
115
+ trialConverted(plan: string, amountCentimes: number, options?: { userId?: string }): void;
116
+ }
117
+
118
+ export declare function createArkadiaClient(deps?: {
119
+ fetch?: typeof fetch;
120
+ storage?: { get(key: string): unknown; set(key: string, value: unknown): void; remove(key: string): void };
121
+ sleep?: (ms: number) => Promise<void>;
122
+ now?: () => Date;
123
+ }): ArkadiaClientInstance;
124
+
125
+ export declare const ArkadiaClient: ArkadiaClientInstance;
126
+ export default ArkadiaClient;
@@ -0,0 +1,126 @@
1
+ export declare const SDK_VERSION: string;
2
+ export declare const DEFAULT_BASE_URL: string;
3
+
4
+ export type ArkadiaEventType =
5
+ | 'app.install' | 'app.update' | 'app.session_start' | 'app.session_end'
6
+ | 'app.screen_view' | 'app.feature_used' | 'app.crash'
7
+ | 'app.onboarding_step' | 'app.onboarding_completed' | 'app.search'
8
+ | 'sale.subscription_started' | 'sale.subscription_renewed'
9
+ | 'sale.subscription_cancelled' | 'sale.subscription_paused'
10
+ | 'sale.subscription_resumed' | 'sale.plan_upgraded' | 'sale.plan_downgraded'
11
+ | 'sale.purchase_completed' | 'sale.module_activated' | 'sale.module_deactivated'
12
+ | 'sale.trial_started' | 'sale.trial_converted' | 'sale.trial_expired'
13
+ | 'sale.refund_issued' | 'sale.coupon_used';
14
+
15
+ export interface RejectedEvent {
16
+ index: number;
17
+ error: 'invalid_event_type' | 'invalid_event';
18
+ message: string;
19
+ }
20
+
21
+ export interface ArkadiaInitOptions {
22
+ /** Clé API : ark_live_… ou ark_test_… */
23
+ apiKey: string;
24
+ /** Code de l'application dans Arkadia. */
25
+ appCode: string;
26
+ /** 'web' | 'node' | 'react-native' | 'android' | 'ios'… (défaut : 'web') */
27
+ platform?: string;
28
+ /** Version de votre app (défaut : '1.0.0'). */
29
+ appVersion?: string;
30
+ /** Logs console si true. */
31
+ debug?: boolean;
32
+ /** URL de base de l'API (défaut : production Arkadia). */
33
+ baseUrl?: string;
34
+ /** Intervalle d'envoi automatique en ms (défaut : 30 000). */
35
+ flushIntervalMs?: number;
36
+ /** Appelé quand le serveur écarte des events invalides d'un lot. */
37
+ onRejected?: (rejected: RejectedEvent[], batch: object[]) => void;
38
+ }
39
+
40
+ export interface TrackOptions {
41
+ properties?: Record<string, unknown>;
42
+ sessionId?: string;
43
+ userId?: string;
44
+ }
45
+
46
+ export interface ArkadiaStatus {
47
+ appCode: string;
48
+ quota: {
49
+ limit: number;
50
+ used_this_month: number;
51
+ remaining: number;
52
+ reset_at: string | null;
53
+ percent_used: number;
54
+ is_unlimited: boolean;
55
+ };
56
+ rate_limit: { limit_per_minute: number; batch_max: number; reads_per_minute: number };
57
+ key: { type: 'live' | 'test'; label: string; expires_at: string | null };
58
+ }
59
+
60
+ export interface ArkadiaDailyAnalytics {
61
+ date: string;
62
+ appCode: string;
63
+ sessions: number;
64
+ uniqueUsers: number;
65
+ avgSessionDuration: number;
66
+ screenViews: number;
67
+ crashes: number;
68
+ installs: number;
69
+ updates: number;
70
+ revenue: number;
71
+ currency: string;
72
+ newSubscriptions: number;
73
+ renewals: number;
74
+ cancellations: number;
75
+ oneTimePurchases: number;
76
+ activeModules: number;
77
+ trialStarts: number;
78
+ trialConversions: number;
79
+ topScreens: { screen: string; views: number }[];
80
+ topFeatures: { feature: string; uses: number }[];
81
+ eventCount: number;
82
+ updatedAt: string | null;
83
+ }
84
+
85
+ export interface ArkadiaAnalytics {
86
+ appCode: string;
87
+ from: string;
88
+ to: string;
89
+ days: ArkadiaDailyAnalytics[];
90
+ }
91
+
92
+ export declare class ArkadiaApiError extends Error {
93
+ readonly status: number;
94
+ readonly code: string | null;
95
+ readonly body: unknown;
96
+ }
97
+
98
+ export interface ArkadiaClientInstance {
99
+ init(options: ArkadiaInitOptions): Promise<void>;
100
+ track(event: ArkadiaEventType | (string & {}), options?: TrackOptions): void;
101
+ flush(): Promise<void>;
102
+ shutdown(): Promise<void>;
103
+ status(): Promise<ArkadiaStatus>;
104
+ analytics(range?: { from?: string; to?: string }): Promise<ArkadiaAnalytics>;
105
+
106
+ screenView(screen: string, options?: { previousScreen?: string; userId?: string; sessionId?: string }): void;
107
+ sessionStart(options?: { userId?: string; sessionId?: string }): void;
108
+ sessionEnd(durationSeconds: number, options?: { userId?: string; sessionId?: string }): void;
109
+ featureUsed(featureName: string, options?: { metadata?: Record<string, unknown>; userId?: string; sessionId?: string }): void;
110
+ crash(errorType: string, errorMessage: unknown, options?: { stackTrace?: unknown; userId?: string; sessionId?: string }): void;
111
+ subscriptionStarted(plan: string, amountCentimes: number, interval: string, options?: { userId?: string; couponCode?: string }): void;
112
+ subscriptionCancelled(plan: string, options?: { reason?: string; daysActive?: number; userId?: string }): void;
113
+ purchaseCompleted(productId: string, productName: string, amountCentimes: number, options?: { userId?: string; couponCode?: string }): void;
114
+ trialStarted(plan: string, trialDays: number, options?: { userId?: string }): void;
115
+ trialConverted(plan: string, amountCentimes: number, options?: { userId?: string }): void;
116
+ }
117
+
118
+ export declare function createArkadiaClient(deps?: {
119
+ fetch?: typeof fetch;
120
+ storage?: { get(key: string): unknown; set(key: string, value: unknown): void; remove(key: string): void };
121
+ sleep?: (ms: number) => Promise<void>;
122
+ now?: () => Date;
123
+ }): ArkadiaClientInstance;
124
+
125
+ export declare const ArkadiaClient: ArkadiaClientInstance;
126
+ export default ArkadiaClient;
package/dist/index.js ADDED
@@ -0,0 +1,316 @@
1
+ // src/index.js
2
+ var SDK_VERSION = "1.1.0";
3
+ var DEFAULT_BASE_URL = "https://europe-west1-arkadiadev.cloudfunctions.net/arkadiaApi";
4
+ var OFFLINE_KEY = "arkadia_offline_queue";
5
+ var MAX_OFFLINE_EVENTS = 500;
6
+ var MAX_RETRIES = 3;
7
+ var MAX_RETRY_AFTER_MS = 6e4;
8
+ var REQUEST_TIMEOUT_MS = 15e3;
9
+ var ArkadiaApiError = class extends Error {
10
+ constructor(status, body) {
11
+ super(body?.message || `Erreur API Arkadia (${status})`);
12
+ this.name = "ArkadiaApiError";
13
+ this.status = status;
14
+ this.code = body?.error ?? null;
15
+ this.body = body;
16
+ }
17
+ };
18
+ function defaultStorage() {
19
+ const mem = {};
20
+ const memory = {
21
+ get: (k) => mem[k] ?? null,
22
+ set: (k, v) => {
23
+ mem[k] = v;
24
+ },
25
+ remove: (k) => {
26
+ delete mem[k];
27
+ }
28
+ };
29
+ try {
30
+ const ls = globalThis.localStorage;
31
+ if (!ls) return memory;
32
+ ls.setItem("__arkadia_test__", "1");
33
+ ls.removeItem("__arkadia_test__");
34
+ return {
35
+ get: (k) => {
36
+ try {
37
+ return JSON.parse(ls.getItem(k));
38
+ } catch {
39
+ return null;
40
+ }
41
+ },
42
+ set: (k, v) => {
43
+ try {
44
+ ls.setItem(k, JSON.stringify(v));
45
+ } catch {
46
+ }
47
+ },
48
+ remove: (k) => {
49
+ try {
50
+ ls.removeItem(k);
51
+ } catch {
52
+ }
53
+ }
54
+ };
55
+ } catch {
56
+ return memory;
57
+ }
58
+ }
59
+ function uuid() {
60
+ const c = globalThis.crypto;
61
+ if (c?.randomUUID) return c.randomUUID();
62
+ const bytes = new Uint8Array(16);
63
+ if (c?.getRandomValues) c.getRandomValues(bytes);
64
+ else for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
65
+ bytes[6] = bytes[6] & 15 | 64;
66
+ bytes[8] = bytes[8] & 63 | 128;
67
+ const h = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
68
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
69
+ }
70
+ var pick = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
71
+ function createArkadiaClient(deps = {}) {
72
+ const fetchFn = deps.fetch ?? ((...args) => globalThis.fetch(...args));
73
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
74
+ const now = deps.now ?? (() => /* @__PURE__ */ new Date());
75
+ let config = null;
76
+ let storage = deps.storage ?? null;
77
+ let timer = null;
78
+ let listenersAttached = false;
79
+ const queue = [];
80
+ const log = (...args) => {
81
+ if (config?.debug) console.log("[Arkadia]", ...args);
82
+ };
83
+ const batchMax = () => config.apiKey.startsWith("ark_test_") ? 20 : 50;
84
+ const url = (path) => `${config.baseUrl.replace(/\/+$/, "")}/v1${path}`;
85
+ const headers = (extra = {}) => ({
86
+ Authorization: `Bearer ${config.apiKey}`,
87
+ "X-Arkadia-Version": "1",
88
+ ...extra
89
+ });
90
+ function loadOffline() {
91
+ const stored = storage.get(OFFLINE_KEY);
92
+ if (!Array.isArray(stored)) return [];
93
+ if (stored.length && !stored[0]?.events) return [{ id: uuid(), events: stored }];
94
+ return stored;
95
+ }
96
+ function saveOffline(events, id) {
97
+ const batches = loadOffline().filter((b) => b.id !== id);
98
+ batches.push({ id, events });
99
+ let total = batches.reduce((n, b) => n + b.events.length, 0);
100
+ while (total > MAX_OFFLINE_EVENTS && batches.length > 1) total -= batches.shift().events.length;
101
+ storage.set(OFFLINE_KEY, batches);
102
+ log(`${events.length} events gard\xE9s hors ligne (total : ${total})`);
103
+ }
104
+ function removeOffline(id) {
105
+ const batches = loadOffline().filter((b) => b.id !== id);
106
+ if (batches.length) storage.set(OFFLINE_KEY, batches);
107
+ else storage.remove(OFFLINE_KEY);
108
+ }
109
+ function retryAfterMs(res, fallback) {
110
+ const seconds = Number(res.headers?.get?.("retry-after"));
111
+ return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds * 1e3, MAX_RETRY_AFTER_MS) : fallback;
112
+ }
113
+ async function sendBatch(events, batchId = uuid()) {
114
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
115
+ let res;
116
+ try {
117
+ res = await fetchFn(url("/events"), {
118
+ method: "POST",
119
+ headers: headers({ "Content-Type": "application/json", "X-Idempotency-Key": batchId }),
120
+ body: JSON.stringify({ appCode: config.appCode, batch: events }),
121
+ signal: AbortSignal.timeout?.(REQUEST_TIMEOUT_MS)
122
+ });
123
+ } catch (err) {
124
+ log(`Erreur r\xE9seau (tentative ${attempt + 1}) :`, err?.message ?? err);
125
+ if (attempt < MAX_RETRIES) await sleep(2 ** attempt * 1e3);
126
+ continue;
127
+ }
128
+ const body = await res.json().catch(() => null);
129
+ if (res.ok) {
130
+ log(`Lot accept\xE9 \u2014 ${body?.ingested ?? events.length} events`);
131
+ if (body?.rejected?.length) {
132
+ log("Events rejet\xE9s :", body.rejected);
133
+ config.onRejected?.(body.rejected, events);
134
+ }
135
+ return "sent";
136
+ }
137
+ if (res.status === 429 && body?.error === "quota_exceeded") {
138
+ log("Quota mensuel atteint \u2014 lot abandonn\xE9");
139
+ return "dropped";
140
+ }
141
+ if (res.status === 429 || res.status === 409) {
142
+ const wait = retryAfterMs(res, 5e3);
143
+ log(`${res.status} \u2014 nouvel essai dans ${Math.round(wait / 1e3)} s`);
144
+ if (attempt < MAX_RETRIES) await sleep(wait);
145
+ continue;
146
+ }
147
+ if (res.status >= 400 && res.status < 500) {
148
+ log(`Erreur ${res.status} (${body?.error}) \u2014 lot abandonn\xE9 :`, body?.message);
149
+ return "dropped";
150
+ }
151
+ log(`Erreur serveur ${res.status} (tentative ${attempt + 1})`);
152
+ if (attempt < MAX_RETRIES) await sleep(2 ** attempt * 1e3);
153
+ }
154
+ saveOffline(events, batchId);
155
+ return "offline";
156
+ }
157
+ async function flushQueue() {
158
+ while (queue.length) {
159
+ await sendBatch(queue.splice(0, batchMax()));
160
+ }
161
+ }
162
+ async function flushOffline() {
163
+ const batches = loadOffline();
164
+ if (!batches.length) return;
165
+ log(`Renvoi de ${batches.length} lot(s) hors ligne`);
166
+ for (const { id, events } of batches) {
167
+ removeOffline(id);
168
+ for (let i = 0; i < events.length; i += batchMax()) {
169
+ const slice = events.slice(i, i + batchMax());
170
+ await sendBatch(slice, events.length <= batchMax() ? id : `${id}-${i}`);
171
+ }
172
+ }
173
+ }
174
+ function flushOnExit() {
175
+ if (!queue.length) return;
176
+ const events = queue.splice(0, batchMax());
177
+ const id = uuid();
178
+ saveOffline(events, id);
179
+ try {
180
+ fetchFn(url("/events"), {
181
+ method: "POST",
182
+ keepalive: true,
183
+ headers: headers({ "Content-Type": "application/json", "X-Idempotency-Key": id }),
184
+ body: JSON.stringify({ appCode: config.appCode, batch: events })
185
+ }).then((res) => {
186
+ if (res.ok) removeOffline(id);
187
+ }, () => {
188
+ });
189
+ } catch {
190
+ }
191
+ }
192
+ async function init(options) {
193
+ if (!options?.apiKey || !options?.appCode) {
194
+ throw new Error("ArkadiaClient.init : apiKey et appCode sont requis.");
195
+ }
196
+ config = {
197
+ platform: "web",
198
+ appVersion: "1.0.0",
199
+ debug: false,
200
+ baseUrl: DEFAULT_BASE_URL,
201
+ flushIntervalMs: 3e4,
202
+ ...options
203
+ };
204
+ storage = storage ?? defaultStorage();
205
+ if (timer) clearInterval(timer);
206
+ timer = setInterval(() => {
207
+ flushQueue().catch(() => {
208
+ });
209
+ }, config.flushIntervalMs);
210
+ timer.unref?.();
211
+ const doc = globalThis.document;
212
+ if (!listenersAttached && globalThis.addEventListener && doc) {
213
+ globalThis.addEventListener("pagehide", flushOnExit);
214
+ doc.addEventListener("visibilitychange", () => {
215
+ if (doc.visibilityState === "hidden") flushOnExit();
216
+ });
217
+ listenersAttached = true;
218
+ }
219
+ log(`SDK ${SDK_VERSION} initialis\xE9 \u2014 appCode ${config.appCode}`);
220
+ await flushOffline();
221
+ }
222
+ function track(event, { properties, sessionId, userId } = {}) {
223
+ if (!config) {
224
+ console.warn(`[Arkadia] SDK non initialis\xE9 \u2014 event ignor\xE9 : ${event}`);
225
+ return;
226
+ }
227
+ queue.push(pick({
228
+ event,
229
+ timestamp: now().toISOString(),
230
+ properties: properties ?? {},
231
+ context: { platform: config.platform, appVersion: config.appVersion, sdkVersion: SDK_VERSION },
232
+ sessionId,
233
+ userId
234
+ }));
235
+ if (queue.length >= batchMax()) flushQueue().catch(() => {
236
+ });
237
+ }
238
+ async function request(path) {
239
+ if (!config) throw new Error("ArkadiaClient non initialis\xE9.");
240
+ const res = await fetchFn(url(path), { headers: headers(), signal: AbortSignal.timeout?.(REQUEST_TIMEOUT_MS) });
241
+ const body = await res.json().catch(() => null);
242
+ if (!res.ok) throw new ArkadiaApiError(res.status, body);
243
+ return body;
244
+ }
245
+ return {
246
+ init,
247
+ track,
248
+ /** Envoie immédiatement tout ce qui est en file. */
249
+ flush: () => flushQueue(),
250
+ /** Arrête le minuteur et envoie ce qui reste (Node, tests, fin de script). */
251
+ async shutdown() {
252
+ if (timer) clearInterval(timer);
253
+ timer = null;
254
+ await flushQueue();
255
+ },
256
+ /** Quota, limites et informations sur la clé. */
257
+ status: () => request("/status"),
258
+ /** Agrégats quotidiens : { from?: 'YYYY-MM-DD', to?: 'YYYY-MM-DD' }. */
259
+ analytics: ({ from, to } = {}) => {
260
+ const qs = new URLSearchParams(pick({ from, to })).toString();
261
+ return request(`/analytics${qs ? `?${qs}` : ""}`);
262
+ },
263
+ // ── Raccourcis ────────────────────────────────────────────────────────────
264
+ screenView(screen, { previousScreen, userId, sessionId } = {}) {
265
+ track("app.screen_view", { properties: pick({ screen, previousScreen }), sessionId, userId });
266
+ },
267
+ sessionStart({ userId, sessionId } = {}) {
268
+ track("app.session_start", { sessionId, userId });
269
+ },
270
+ sessionEnd(durationSeconds, { userId, sessionId } = {}) {
271
+ track("app.session_end", { properties: { durationSeconds }, sessionId, userId });
272
+ },
273
+ featureUsed(featureName, { metadata, userId, sessionId } = {}) {
274
+ track("app.feature_used", { properties: pick({ featureName, metadata }), sessionId, userId });
275
+ },
276
+ /** Crash : envoyé immédiatement. */
277
+ crash(errorType, errorMessage, { stackTrace, userId, sessionId } = {}) {
278
+ track("app.crash", {
279
+ properties: pick({
280
+ errorType,
281
+ errorMessage: String(errorMessage).slice(0, 500),
282
+ stackTrace: stackTrace != null ? String(stackTrace).slice(0, 2e3) : null
283
+ }),
284
+ sessionId,
285
+ userId
286
+ });
287
+ flushQueue().catch(() => {
288
+ });
289
+ },
290
+ subscriptionStarted(plan, amountCentimes, interval, { userId, couponCode } = {}) {
291
+ track("sale.subscription_started", { properties: pick({ plan, amount: amountCentimes, interval, couponCode }), userId });
292
+ },
293
+ subscriptionCancelled(plan, { reason, daysActive, userId } = {}) {
294
+ track("sale.subscription_cancelled", { properties: pick({ plan, reason, daysActive }), userId });
295
+ },
296
+ purchaseCompleted(productId, productName, amountCentimes, { userId, couponCode } = {}) {
297
+ track("sale.purchase_completed", { properties: pick({ productId, productName, amount: amountCentimes, couponCode }), userId });
298
+ },
299
+ trialStarted(plan, trialDays, { userId } = {}) {
300
+ track("sale.trial_started", { properties: { plan, trialDays }, userId });
301
+ },
302
+ trialConverted(plan, amountCentimes, { userId } = {}) {
303
+ track("sale.trial_converted", { properties: { plan, amount: amountCentimes }, userId });
304
+ }
305
+ };
306
+ }
307
+ var ArkadiaClient = createArkadiaClient();
308
+ var index_default = ArkadiaClient;
309
+ export {
310
+ ArkadiaApiError,
311
+ ArkadiaClient,
312
+ DEFAULT_BASE_URL,
313
+ SDK_VERSION,
314
+ createArkadiaClient,
315
+ index_default as default
316
+ };
@@ -0,0 +1,69 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __export = (target, all) => {
6
+ for (var name in all)
7
+ __defProp(target, name, { get: all[name], enumerable: true });
8
+ };
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
+
19
+ // src/webhooks.js
20
+ var webhooks_exports = {};
21
+ __export(webhooks_exports, {
22
+ WebhookSignatureError: () => WebhookSignatureError,
23
+ constructWebhookEvent: () => constructWebhookEvent,
24
+ verifyWebhookSignature: () => verifyWebhookSignature
25
+ });
26
+ module.exports = __toCommonJS(webhooks_exports);
27
+ var import_node_crypto = require("node:crypto");
28
+ var WebhookSignatureError = class extends Error {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = "WebhookSignatureError";
32
+ }
33
+ };
34
+ function parseHeader(header) {
35
+ const parts = {};
36
+ for (const item of String(header ?? "").split(",")) {
37
+ const i = item.indexOf("=");
38
+ if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
39
+ }
40
+ return { timestamp: Number(parts.t), signature: parts.v1 };
41
+ }
42
+ function verifyWebhookSignature(payload, header, secret, { toleranceSeconds = 300, now = Date.now() } = {}) {
43
+ if (!secret) throw new WebhookSignatureError("Secret de webhook manquant.");
44
+ const { timestamp, signature } = parseHeader(header);
45
+ if (!Number.isFinite(timestamp) || !signature) {
46
+ throw new WebhookSignatureError("En-t\xEAte X-Arkadia-Signature absent ou mal form\xE9.");
47
+ }
48
+ if (toleranceSeconds > 0 && Math.abs(now / 1e3 - timestamp) > toleranceSeconds) {
49
+ throw new WebhookSignatureError("Signature expir\xE9e (horodatage hors tol\xE9rance).");
50
+ }
51
+ const body = typeof payload === "string" ? payload : Buffer.from(payload).toString("utf8");
52
+ const expected = (0, import_node_crypto.createHmac)("sha256", secret).update(`${timestamp}.${body}`).digest();
53
+ const received = Buffer.from(signature, "hex");
54
+ if (received.length !== expected.length || !(0, import_node_crypto.timingSafeEqual)(received, expected)) {
55
+ throw new WebhookSignatureError("Signature invalide.");
56
+ }
57
+ return true;
58
+ }
59
+ function constructWebhookEvent(payload, header, secret, options) {
60
+ verifyWebhookSignature(payload, header, secret, options);
61
+ const body = typeof payload === "string" ? payload : Buffer.from(payload).toString("utf8");
62
+ return JSON.parse(body);
63
+ }
64
+ // Annotate the CommonJS export names for ESM import in node:
65
+ 0 && (module.exports = {
66
+ WebhookSignatureError,
67
+ constructWebhookEvent,
68
+ verifyWebhookSignature
69
+ });
@@ -0,0 +1,34 @@
1
+ export declare class WebhookSignatureError extends Error {}
2
+
3
+ export interface VerifyOptions {
4
+ /** Écart maximal accepté avec l'horodatage signé, en secondes (défaut : 300 ; 0 = désactivé). */
5
+ toleranceSeconds?: number;
6
+ /** Horloge (ms epoch), pour les tests. */
7
+ now?: number;
8
+ }
9
+
10
+ export type ArkadiaWebhookType = 'events' | 'quota.warning' | 'quota.exceeded' | 'webhook.test';
11
+
12
+ export interface ArkadiaWebhookEvent<T = Record<string, unknown>> {
13
+ /** Identifiant unique de la livraison : à utiliser pour dédupliquer. */
14
+ id: string;
15
+ type: ArkadiaWebhookType;
16
+ created: string;
17
+ livemode: boolean;
18
+ appCode: string;
19
+ data: T;
20
+ }
21
+
22
+ export declare function verifyWebhookSignature(
23
+ payload: string | Uint8Array,
24
+ header: string | null | undefined,
25
+ secret: string,
26
+ options?: VerifyOptions,
27
+ ): true;
28
+
29
+ export declare function constructWebhookEvent<T = Record<string, unknown>>(
30
+ payload: string | Uint8Array,
31
+ header: string | null | undefined,
32
+ secret: string,
33
+ options?: VerifyOptions,
34
+ ): ArkadiaWebhookEvent<T>;
@@ -0,0 +1,34 @@
1
+ export declare class WebhookSignatureError extends Error {}
2
+
3
+ export interface VerifyOptions {
4
+ /** Écart maximal accepté avec l'horodatage signé, en secondes (défaut : 300 ; 0 = désactivé). */
5
+ toleranceSeconds?: number;
6
+ /** Horloge (ms epoch), pour les tests. */
7
+ now?: number;
8
+ }
9
+
10
+ export type ArkadiaWebhookType = 'events' | 'quota.warning' | 'quota.exceeded' | 'webhook.test';
11
+
12
+ export interface ArkadiaWebhookEvent<T = Record<string, unknown>> {
13
+ /** Identifiant unique de la livraison : à utiliser pour dédupliquer. */
14
+ id: string;
15
+ type: ArkadiaWebhookType;
16
+ created: string;
17
+ livemode: boolean;
18
+ appCode: string;
19
+ data: T;
20
+ }
21
+
22
+ export declare function verifyWebhookSignature(
23
+ payload: string | Uint8Array,
24
+ header: string | null | undefined,
25
+ secret: string,
26
+ options?: VerifyOptions,
27
+ ): true;
28
+
29
+ export declare function constructWebhookEvent<T = Record<string, unknown>>(
30
+ payload: string | Uint8Array,
31
+ header: string | null | undefined,
32
+ secret: string,
33
+ options?: VerifyOptions,
34
+ ): ArkadiaWebhookEvent<T>;
@@ -0,0 +1,43 @@
1
+ // src/webhooks.js
2
+ import { createHmac, timingSafeEqual } from "node:crypto";
3
+ var WebhookSignatureError = class extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "WebhookSignatureError";
7
+ }
8
+ };
9
+ function parseHeader(header) {
10
+ const parts = {};
11
+ for (const item of String(header ?? "").split(",")) {
12
+ const i = item.indexOf("=");
13
+ if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
14
+ }
15
+ return { timestamp: Number(parts.t), signature: parts.v1 };
16
+ }
17
+ function verifyWebhookSignature(payload, header, secret, { toleranceSeconds = 300, now = Date.now() } = {}) {
18
+ if (!secret) throw new WebhookSignatureError("Secret de webhook manquant.");
19
+ const { timestamp, signature } = parseHeader(header);
20
+ if (!Number.isFinite(timestamp) || !signature) {
21
+ throw new WebhookSignatureError("En-t\xEAte X-Arkadia-Signature absent ou mal form\xE9.");
22
+ }
23
+ if (toleranceSeconds > 0 && Math.abs(now / 1e3 - timestamp) > toleranceSeconds) {
24
+ throw new WebhookSignatureError("Signature expir\xE9e (horodatage hors tol\xE9rance).");
25
+ }
26
+ const body = typeof payload === "string" ? payload : Buffer.from(payload).toString("utf8");
27
+ const expected = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest();
28
+ const received = Buffer.from(signature, "hex");
29
+ if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
30
+ throw new WebhookSignatureError("Signature invalide.");
31
+ }
32
+ return true;
33
+ }
34
+ function constructWebhookEvent(payload, header, secret, options) {
35
+ verifyWebhookSignature(payload, header, secret, options);
36
+ const body = typeof payload === "string" ? payload : Buffer.from(payload).toString("utf8");
37
+ return JSON.parse(body);
38
+ }
39
+ export {
40
+ WebhookSignatureError,
41
+ constructWebhookEvent,
42
+ verifyWebhookSignature
43
+ };
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@arkadiadev/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "SDK officiel de l'API Arkadia : analytics et ventes de vos apps, vérification des webhooks.",
5
+ "keywords": ["arkadia", "analytics", "sdk", "webhooks"],
6
+ "homepage": "https://arkadiadev.web.app",
7
+ "license": "MIT",
8
+ "author": "Arkadia Dev",
9
+ "type": "module",
10
+ "sideEffects": false,
11
+ "main": "./dist/index.cjs",
12
+ "module": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "unpkg": "./dist/arkadia.global.js",
15
+ "jsdelivr": "./dist/arkadia.global.js",
16
+ "exports": {
17
+ ".": {
18
+ "import": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "require": {
23
+ "types": "./dist/index.d.cts",
24
+ "default": "./dist/index.cjs"
25
+ }
26
+ },
27
+ "./webhooks": {
28
+ "import": {
29
+ "types": "./dist/webhooks.d.ts",
30
+ "default": "./dist/webhooks.js"
31
+ },
32
+ "require": {
33
+ "types": "./dist/webhooks.d.cts",
34
+ "default": "./dist/webhooks.cjs"
35
+ }
36
+ },
37
+ "./package.json": "./package.json"
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "README.md",
42
+ "CHANGELOG.md",
43
+ "LICENSE"
44
+ ],
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "scripts": {
49
+ "build": "node build.mjs",
50
+ "test": "node --test test/*.test.js",
51
+ "prepublishOnly": "npm run build && npm test"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "devDependencies": {
57
+ "esbuild": "0.24.2"
58
+ }
59
+ }