@nurdd/web-sdk 1.0.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 +434 -0
- package/adapters/angular.js +48 -0
- package/adapters/next.js +25 -0
- package/adapters/nuxt.js +38 -0
- package/adapters/react.js +87 -0
- package/adapters/vue.js +67 -0
- package/dist/nurdd-sdk.umd.js +1132 -0
- package/dist/package.json +3 -0
- package/integrations/shopify/README.md +172 -0
- package/integrations/shopify/checkout-pixel.js +63 -0
- package/integrations/shopify/orders-webhook.example.js +69 -0
- package/package.json +87 -0
- package/src/autotrack.js +88 -0
- package/src/deeplinks.js +79 -0
- package/src/device.js +78 -0
- package/src/env.js +21 -0
- package/src/identity.js +57 -0
- package/src/index.js +12 -0
- package/src/logger.js +12 -0
- package/src/queue.js +102 -0
- package/src/sdk.js +342 -0
- package/src/session.js +83 -0
- package/src/storage.js +48 -0
- package/src/transport.js +105 -0
- package/src/utils.js +56 -0
- package/src/utm.js +46 -0
- package/types/angular.d.ts +30 -0
- package/types/index.d.ts +172 -0
- package/types/next.d.ts +10 -0
- package/types/nuxt.d.ts +5 -0
- package/types/react.d.ts +28 -0
- package/types/vue.d.ts +21 -0
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* @nurdd/web-sdk — UMD bundle
|
|
3
|
+
* Framework-agnostic attribution & analytics SDK.
|
|
4
|
+
* Exposes `window.Nurdd` (also CommonJS / AMD compatible).
|
|
5
|
+
*/
|
|
6
|
+
;(function (global, factory) {
|
|
7
|
+
if (typeof exports === "object" && typeof module !== "undefined") {
|
|
8
|
+
module.exports = factory();
|
|
9
|
+
} else if (typeof define === "function" && define.amd) {
|
|
10
|
+
define(factory);
|
|
11
|
+
} else {
|
|
12
|
+
global.Nurdd = factory();
|
|
13
|
+
}
|
|
14
|
+
})(typeof self !== "undefined" ? self : this, function () {
|
|
15
|
+
"use strict";
|
|
16
|
+
|
|
17
|
+
// ===== src/env.js =====
|
|
18
|
+
|
|
19
|
+
// Runtime detection so the same module works in browsers, SSR, web workers,
|
|
20
|
+
// React Native (via metro shimming `window`), and any other JS host.
|
|
21
|
+
typeof window !== "undefined" &&
|
|
22
|
+
typeof document !== "undefined" &&
|
|
23
|
+
typeof navigator !== "undefined";
|
|
24
|
+
try {
|
|
25
|
+
if (!isBrowser) return false;
|
|
26
|
+
const k = "__nurdd_probe__";
|
|
27
|
+
window.localStorage.setItem(k, "1");
|
|
28
|
+
window.localStorage.removeItem(k);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
// ===== src/utils.js =====
|
|
37
|
+
|
|
38
|
+
// Tiny helpers used across the SDK. No dependencies on env so they're
|
|
39
|
+
// safe to import from SSR contexts.
|
|
40
|
+
// Prefer the platform impl when available — it's cryptographically random.
|
|
41
|
+
if (typeof globalThis.crypto?.randomUUID === "function") {
|
|
42
|
+
return globalThis.crypto.randomUUID();
|
|
43
|
+
}
|
|
44
|
+
// RFC 4122 v4-ish fallback. Fine for device IDs / event IDs.
|
|
45
|
+
const rnd = (n) => Math.floor(Math.random() * n);
|
|
46
|
+
const seg = (n) => Array.from({ length: n }, () => rnd(16).toString(16)).join("");
|
|
47
|
+
return `${seg(8)}-${seg(4)}-4${seg(3)}-${(8 + rnd(4)).toString(16)}${seg(3)}-${seg(12)}`;
|
|
48
|
+
}
|
|
49
|
+
try { return JSON.parse(value); } catch { return fallback; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Stable hash, ASCII-friendly. Not cryptographic — used only for client
|
|
53
|
+
// fingerprint hints; the backend computes the canonical fingerprint.
|
|
54
|
+
let h = 2166136261 >>> 0;
|
|
55
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
56
|
+
h ^= str.charCodeAt(i);
|
|
57
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
|
58
|
+
}
|
|
59
|
+
return h.toString(16).padStart(8, "0");
|
|
60
|
+
}
|
|
61
|
+
let t = null;
|
|
62
|
+
return (...args) => {
|
|
63
|
+
if (t) clearTimeout(t);
|
|
64
|
+
t = setTimeout(() => fn(...args), wait);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Merges options with a defaults object, allowing nested objects but not
|
|
69
|
+
// arrays (consumer arrays always replace defaults — fewer surprises).
|
|
70
|
+
if (!overrides) return { ...defaults };
|
|
71
|
+
const out = { ...defaults };
|
|
72
|
+
for (const [k, v] of Object.entries(overrides)) {
|
|
73
|
+
if (v === undefined) continue;
|
|
74
|
+
if (v && typeof v === "object" && !Array.isArray(v) && defaults[k] && typeof defaults[k] === "object") {
|
|
75
|
+
out[k] = { ...defaults[k], ...v };
|
|
76
|
+
} else {
|
|
77
|
+
out[k] = v;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
// ===== src/logger.js =====
|
|
85
|
+
|
|
86
|
+
// Minimal, level-gated logger. `debug` defaults to OFF so the SDK is
|
|
87
|
+
// silent in production unless the host explicitly opts in.
|
|
88
|
+
const noop = () => {};
|
|
89
|
+
if (!enabled) return { debug: noop, info: noop, warn: console?.warn?.bind(console) || noop, error: console?.error?.bind(console) || noop };
|
|
90
|
+
return {
|
|
91
|
+
debug: console.debug?.bind(console, prefix) || noop,
|
|
92
|
+
info: console.info?.bind(console, prefix) || noop,
|
|
93
|
+
warn: console.warn?.bind(console, prefix) || noop,
|
|
94
|
+
error: console.error?.bind(console, prefix) || noop,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
// ===== src/storage.js =====
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
// Tiered storage. localStorage is preferred (persists across tabs and
|
|
103
|
+
// across browser restarts); when unavailable (private mode, embedded
|
|
104
|
+
// webviews, SSR) we fall back to an in-memory map so the SDK keeps
|
|
105
|
+
// functioning without crashing.
|
|
106
|
+
|
|
107
|
+
class MemoryStorage {
|
|
108
|
+
constructor() { this.map = new Map(); }
|
|
109
|
+
get(k) { return this.map.get(k) ?? null; }
|
|
110
|
+
set(k, v) { this.map.set(k, v); }
|
|
111
|
+
remove(k) { this.map.delete(k); }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
class LocalStorageBacked {
|
|
115
|
+
constructor(prefix) { this.prefix = prefix; }
|
|
116
|
+
_key(k) { return `${this.prefix}${k}`; }
|
|
117
|
+
get(k) {
|
|
118
|
+
try { return window.localStorage.getItem(this._key(k)); }
|
|
119
|
+
catch { return null; }
|
|
120
|
+
}
|
|
121
|
+
set(k, v) {
|
|
122
|
+
try { window.localStorage.setItem(this._key(k), v); }
|
|
123
|
+
catch { /* quota / private mode */ }
|
|
124
|
+
}
|
|
125
|
+
remove(k) {
|
|
126
|
+
try { window.localStorage.removeItem(this._key(k)); }
|
|
127
|
+
catch { /* noop */ }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const backing = hasLocalStorage ? new LocalStorageBacked(prefix) : new MemoryStorage();
|
|
131
|
+
return {
|
|
132
|
+
getString(key) { return backing.get(key); },
|
|
133
|
+
setString(key, value) { backing.set(key, value); },
|
|
134
|
+
getJson(key, fallback = null) {
|
|
135
|
+
const raw = backing.get(key);
|
|
136
|
+
if (raw == null) return fallback;
|
|
137
|
+
return safeJsonParse(raw, fallback);
|
|
138
|
+
},
|
|
139
|
+
setJson(key, value) { backing.set(key, JSON.stringify(value)); },
|
|
140
|
+
remove(key) { backing.remove(key); },
|
|
141
|
+
isPersistent: hasLocalStorage,
|
|
142
|
+
isBrowser,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
// ===== src/device.js =====
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
const DEVICE_ID_KEY = "device_id";
|
|
151
|
+
|
|
152
|
+
// Builds the device payload the backend expects on every event. The
|
|
153
|
+
// backend re-validates and fills in anything we miss server-side.
|
|
154
|
+
let deviceId = storage.getString(DEVICE_ID_KEY);
|
|
155
|
+
if (!deviceId) {
|
|
156
|
+
deviceId = uuid();
|
|
157
|
+
storage.setString(DEVICE_ID_KEY, deviceId);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!isBrowser) {
|
|
161
|
+
return {
|
|
162
|
+
device_id: deviceId,
|
|
163
|
+
platform: "server",
|
|
164
|
+
sdk_version: SDK_VERSION,
|
|
165
|
+
app_version: appVersion || null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const nav = window.navigator || {};
|
|
170
|
+
const screen = window.screen || {};
|
|
171
|
+
const locale = nav.language || nav.userLanguage || null;
|
|
172
|
+
let timezone = null;
|
|
173
|
+
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch { /* ignore */ }
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
device_id: deviceId,
|
|
177
|
+
platform: "web",
|
|
178
|
+
os_name: parseOsName(nav.userAgent || ""),
|
|
179
|
+
os_version: null,
|
|
180
|
+
model: null,
|
|
181
|
+
manufacturer: null,
|
|
182
|
+
app_version: appVersion || null,
|
|
183
|
+
sdk_version: SDK_VERSION,
|
|
184
|
+
locale,
|
|
185
|
+
timezone,
|
|
186
|
+
attributes: {
|
|
187
|
+
user_agent: nav.userAgent || null,
|
|
188
|
+
screen: `${screen.width || 0}x${screen.height || 0}`,
|
|
189
|
+
viewport: `${window.innerWidth || 0}x${window.innerHeight || 0}`,
|
|
190
|
+
color_depth: screen.colorDepth || null,
|
|
191
|
+
pixel_ratio: window.devicePixelRatio || 1,
|
|
192
|
+
hardware_concurrency: nav.hardwareConcurrency || null,
|
|
193
|
+
do_not_track: nav.doNotTrack === "1" || nav.doNotTrack === "yes",
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parseOsName(ua) {
|
|
199
|
+
if (/Windows NT/i.test(ua)) return "Windows";
|
|
200
|
+
if (/Mac OS X/i.test(ua)) return "macOS";
|
|
201
|
+
if (/Android/i.test(ua)) return "Android";
|
|
202
|
+
if (/iPhone|iPad|iPod/i.test(ua)) return "iOS";
|
|
203
|
+
if (/Linux/i.test(ua)) return "Linux";
|
|
204
|
+
if (/CrOS/i.test(ua)) return "ChromeOS";
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Local fingerprint hint. The server computes the authoritative
|
|
209
|
+
// fingerprint from IP+UA, but we provide a stable client value so
|
|
210
|
+
// clicks and installs can also match in pure-frontend flows.
|
|
211
|
+
const a = device.attributes || {};
|
|
212
|
+
return fnv1a([
|
|
213
|
+
device.platform,
|
|
214
|
+
device.os_name,
|
|
215
|
+
device.locale,
|
|
216
|
+
device.timezone,
|
|
217
|
+
a.screen,
|
|
218
|
+
a.color_depth,
|
|
219
|
+
a.pixel_ratio,
|
|
220
|
+
a.hardware_concurrency,
|
|
221
|
+
].join("|"));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
// ===== src/utm.js =====
|
|
226
|
+
|
|
227
|
+
const UTM_KEYS = [
|
|
228
|
+
"utm_source",
|
|
229
|
+
"utm_medium",
|
|
230
|
+
"utm_campaign",
|
|
231
|
+
"utm_term",
|
|
232
|
+
"utm_content",
|
|
233
|
+
"utm_id",
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
// Reads UTM params and a few common attribution params from `location.search`.
|
|
237
|
+
// Stable across SPA navigations because the SDK re-reads on every page view.
|
|
238
|
+
if (!isBrowser) return {};
|
|
239
|
+
try {
|
|
240
|
+
const params = new URLSearchParams(window.location.search);
|
|
241
|
+
const out = {};
|
|
242
|
+
for (const key of UTM_KEYS) {
|
|
243
|
+
const v = params.get(key);
|
|
244
|
+
if (v) out[key] = v;
|
|
245
|
+
}
|
|
246
|
+
// Auxiliary identifiers we commonly want to attribute on.
|
|
247
|
+
const short = params.get("sdk_short_code") || params.get("nurdd_shortcode");
|
|
248
|
+
if (short) out.short_code = short;
|
|
249
|
+
const click = params.get("click_id") || params.get("gclid") || params.get("fbclid");
|
|
250
|
+
if (click) out.click_id = click;
|
|
251
|
+
return out;
|
|
252
|
+
} catch {
|
|
253
|
+
return {};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Strips UTM params from the current URL so a single visit doesn't get
|
|
258
|
+
// counted on every subsequent page view. Best-effort: requires history API.
|
|
259
|
+
if (!isBrowser || !window.history?.replaceState) return;
|
|
260
|
+
try {
|
|
261
|
+
const url = new URL(window.location.href);
|
|
262
|
+
let changed = false;
|
|
263
|
+
for (const k of UTM_KEYS) {
|
|
264
|
+
if (url.searchParams.has(k)) { url.searchParams.delete(k); changed = true; }
|
|
265
|
+
}
|
|
266
|
+
if (changed) window.history.replaceState({}, "", url.toString());
|
|
267
|
+
} catch { /* ignore */ }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
// ===== src/transport.js =====
|
|
272
|
+
|
|
273
|
+
// Network transport. Designed around three constraints:
|
|
274
|
+
// 1. The page may close mid-request (unload) — we use `sendBeacon` /
|
|
275
|
+
// `keepalive: true` whenever possible so events still ship.
|
|
276
|
+
// 2. The network may be flaky — retries use exponential backoff with
|
|
277
|
+
// jitter; 5xx and network errors are retried, 4xx are not.
|
|
278
|
+
// 3. We never throw into the host app — every public call resolves with
|
|
279
|
+
// an { ok, status, data, error } shape.
|
|
280
|
+
|
|
281
|
+
const RETRY_STATUSES = new Set([0, 408, 425, 429, 500, 502, 503, 504]);
|
|
282
|
+
constructor({ baseUrl, sdkKey, logger, maxRetries = 4, baseRetryDelayMs = 250, timeoutMs = 15_000 }) {
|
|
283
|
+
if (!baseUrl) throw new Error("[nurdd] baseUrl is required");
|
|
284
|
+
if (!sdkKey) throw new Error("[nurdd] sdkKey is required");
|
|
285
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
286
|
+
this.sdkKey = sdkKey;
|
|
287
|
+
this.logger = logger;
|
|
288
|
+
this.maxRetries = maxRetries;
|
|
289
|
+
this.baseRetryDelayMs = baseRetryDelayMs;
|
|
290
|
+
this.timeoutMs = timeoutMs;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
_headers() {
|
|
294
|
+
return {
|
|
295
|
+
"Content-Type": "application/json",
|
|
296
|
+
"X-SDK-Key": this.sdkKey,
|
|
297
|
+
"X-SDK-Platform": "web",
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
_backoff(attempt) {
|
|
302
|
+
const jitter = Math.random() * this.baseRetryDelayMs;
|
|
303
|
+
return this.baseRetryDelayMs * 2 ** attempt + jitter;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async request(path, { method = "GET", body, beacon = false, signal } = {}) {
|
|
307
|
+
const url = `${this.baseUrl}${path}`;
|
|
308
|
+
const init = {
|
|
309
|
+
method,
|
|
310
|
+
headers: this._headers(),
|
|
311
|
+
// `keepalive` lets fetch survive page navigation. Capped at 64KB.
|
|
312
|
+
keepalive: beacon === true || method !== "GET",
|
|
313
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
314
|
+
signal,
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
let lastError = null;
|
|
318
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
|
|
319
|
+
try {
|
|
320
|
+
const ac = new AbortController();
|
|
321
|
+
const timeoutId = setTimeout(() => ac.abort(), this.timeoutMs);
|
|
322
|
+
// Combine the consumer-provided signal with our own timeout.
|
|
323
|
+
if (signal) signal.addEventListener("abort", () => ac.abort(), { once: true });
|
|
324
|
+
|
|
325
|
+
const res = await fetch(url, { ...init, signal: ac.signal });
|
|
326
|
+
clearTimeout(timeoutId);
|
|
327
|
+
|
|
328
|
+
if (res.ok) {
|
|
329
|
+
const data = await this._parseBody(res);
|
|
330
|
+
return { ok: true, status: res.status, data };
|
|
331
|
+
}
|
|
332
|
+
if (!RETRY_STATUSES.has(res.status) || attempt === this.maxRetries) {
|
|
333
|
+
const data = await this._parseBody(res);
|
|
334
|
+
return { ok: false, status: res.status, data, error: data?.error || `HTTP ${res.status}` };
|
|
335
|
+
}
|
|
336
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
lastError = err;
|
|
339
|
+
if (attempt === this.maxRetries) {
|
|
340
|
+
return { ok: false, status: 0, error: err?.message || "Network error" };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
await new Promise((r) => setTimeout(r, this._backoff(attempt)));
|
|
344
|
+
}
|
|
345
|
+
return { ok: false, status: 0, error: lastError?.message || "Unknown error" };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async _parseBody(res) {
|
|
349
|
+
const text = await res.text();
|
|
350
|
+
if (!text) return null;
|
|
351
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Fire-and-forget delivery used during page unload. Falls back to
|
|
355
|
+
// a keepalive fetch when sendBeacon isn't available.
|
|
356
|
+
deliverBeacon(path, body) {
|
|
357
|
+
const url = `${this.baseUrl}${path}`;
|
|
358
|
+
const payload = body ? JSON.stringify(body) : "{}";
|
|
359
|
+
try {
|
|
360
|
+
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
|
|
361
|
+
// Beacon requires Content-Type via the Blob constructor.
|
|
362
|
+
const blob = new Blob([payload], { type: "application/json" });
|
|
363
|
+
// Pass auth as a query string so beacon (which can't set headers) still authenticates.
|
|
364
|
+
const sep = url.includes("?") ? "&" : "?";
|
|
365
|
+
return navigator.sendBeacon(`${url}${sep}sdk_key=${encodeURIComponent(this.sdkKey)}`, blob);
|
|
366
|
+
}
|
|
367
|
+
} catch { /* fall through to fetch */ }
|
|
368
|
+
try {
|
|
369
|
+
fetch(url, {
|
|
370
|
+
method: "POST", headers: this._headers(), body: payload, keepalive: true,
|
|
371
|
+
}).catch(() => {});
|
|
372
|
+
return true;
|
|
373
|
+
} catch { return false; }
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
// ===== src/queue.js =====
|
|
379
|
+
|
|
380
|
+
// Offline-first event queue. Events are persisted to storage as soon as
|
|
381
|
+
// they're tracked so a tab crash or unload doesn't lose them. A flush
|
|
382
|
+
// timer ships them as a batch; on failure the batch is requeued.
|
|
383
|
+
//
|
|
384
|
+
// The queue is intentionally simple: a single sorted list. For very
|
|
385
|
+
// high volumes (>1k events/min) consider sharding by event kind.
|
|
386
|
+
|
|
387
|
+
const QUEUE_KEY = "event_queue";
|
|
388
|
+
constructor({ storage, transport, logger, flushIntervalMs = 5_000, maxBatchSize = 50, onFlush }) {
|
|
389
|
+
this.storage = storage;
|
|
390
|
+
this.transport = transport;
|
|
391
|
+
this.logger = logger;
|
|
392
|
+
this.flushIntervalMs = flushIntervalMs;
|
|
393
|
+
this.maxBatchSize = maxBatchSize;
|
|
394
|
+
this.onFlush = onFlush || (() => {});
|
|
395
|
+
this.timer = null;
|
|
396
|
+
this.flushing = false;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
start() {
|
|
400
|
+
if (this.timer) return;
|
|
401
|
+
this.timer = setInterval(() => this.flush().catch(() => {}), this.flushIntervalMs);
|
|
402
|
+
// Don't keep a node test process alive; this is a noop in browsers.
|
|
403
|
+
if (typeof this.timer.unref === "function") this.timer.unref();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
stop() {
|
|
407
|
+
if (this.timer) clearInterval(this.timer);
|
|
408
|
+
this.timer = null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
enqueue(event) {
|
|
412
|
+
const list = this._read();
|
|
413
|
+
list.push(event);
|
|
414
|
+
this._write(list);
|
|
415
|
+
// Opportunistic flush so tiny apps don't wait for the timer.
|
|
416
|
+
if (list.length >= this.maxBatchSize) this.flush().catch(() => {});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
size() { return this._read().length; }
|
|
420
|
+
|
|
421
|
+
async flush() {
|
|
422
|
+
if (this.flushing) return { ok: true, skipped: true };
|
|
423
|
+
const all = this._read();
|
|
424
|
+
if (all.length === 0) return { ok: true, count: 0 };
|
|
425
|
+
|
|
426
|
+
this.flushing = true;
|
|
427
|
+
try {
|
|
428
|
+
const batch = all.slice(0, this.maxBatchSize);
|
|
429
|
+
const remaining = all.slice(batch.length);
|
|
430
|
+
|
|
431
|
+
const res = await this.transport.request("/api/sdk/v1/events/batch", {
|
|
432
|
+
method: "POST",
|
|
433
|
+
body: { events: batch },
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
if (!res.ok && res.status !== 207) {
|
|
437
|
+
// Transport gave up after retries — keep the batch for later.
|
|
438
|
+
this.logger.warn("flush failed, will retry later", res.error || res.status);
|
|
439
|
+
return { ok: false, count: 0, error: res.error };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 207 multi-status returns per-event success; we only drop the
|
|
443
|
+
// events the backend accepted, requeue the rest.
|
|
444
|
+
const accepted = new Set(
|
|
445
|
+
(res.data?.results || [])
|
|
446
|
+
.filter((r) => r.ok)
|
|
447
|
+
.map((r) => r.event_id)
|
|
448
|
+
.filter(Boolean),
|
|
449
|
+
);
|
|
450
|
+
const rejectedFromBatch = batch.filter(
|
|
451
|
+
(e) => e.event_id && !accepted.has(e.event_id),
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
// If we have per-event ids, only requeue the rejected ones; if not,
|
|
455
|
+
// assume all succeeded on a 2xx response.
|
|
456
|
+
const requeue =
|
|
457
|
+
accepted.size > 0 || rejectedFromBatch.length > 0
|
|
458
|
+
? [...rejectedFromBatch.filter((e) => !accepted.has(e.event_id)), ...remaining]
|
|
459
|
+
: remaining;
|
|
460
|
+
|
|
461
|
+
this._write(requeue);
|
|
462
|
+
this.onFlush({ sent: batch.length - rejectedFromBatch.length, queued: requeue.length });
|
|
463
|
+
return { ok: true, count: batch.length };
|
|
464
|
+
} finally {
|
|
465
|
+
this.flushing = false;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Ship whatever is queued without retries — used on page unload.
|
|
470
|
+
flushBeacon() {
|
|
471
|
+
const all = this._read();
|
|
472
|
+
if (all.length === 0) return;
|
|
473
|
+
const ok = this.transport.deliverBeacon("/api/sdk/v1/events/batch", { events: all });
|
|
474
|
+
if (ok) this._write([]);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
_read() { return this.storage.getJson(QUEUE_KEY, []) || []; }
|
|
478
|
+
_write(list) { this.storage.setJson(QUEUE_KEY, list); }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
// ===== src/session.js =====
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
const SESSION_KEY = "session";
|
|
486
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60_000; // 30 min idle = new session
|
|
487
|
+
|
|
488
|
+
// Tracks the current logical session. A session ends when the tab has
|
|
489
|
+
// been idle for longer than `timeoutMs` since the last tracked event,
|
|
490
|
+
// or when `end()` is called explicitly. New sessions reuse the device
|
|
491
|
+
// id but get a fresh session id.
|
|
492
|
+
constructor({ storage, transport, logger, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
493
|
+
this.storage = storage;
|
|
494
|
+
this.transport = transport;
|
|
495
|
+
this.logger = logger;
|
|
496
|
+
this.timeoutMs = timeoutMs;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
_read() { return this.storage.getJson(SESSION_KEY, null); }
|
|
500
|
+
_write(s) { this.storage.setJson(SESSION_KEY, s); }
|
|
501
|
+
_clear() { this.storage.remove(SESSION_KEY); }
|
|
502
|
+
|
|
503
|
+
// Returns the active session, starting a new one if none exists or
|
|
504
|
+
// the previous one has expired. Updates last-touch on every call.
|
|
505
|
+
touch({ utm, referrer } = {}) {
|
|
506
|
+
const now = Date.now();
|
|
507
|
+
const existing = this._read();
|
|
508
|
+
if (existing && now - existing.lastTouchedAt < this.timeoutMs) {
|
|
509
|
+
existing.lastTouchedAt = now;
|
|
510
|
+
this._write(existing);
|
|
511
|
+
return existing;
|
|
512
|
+
}
|
|
513
|
+
const fresh = {
|
|
514
|
+
id: uuid(),
|
|
515
|
+
startedAt: now,
|
|
516
|
+
lastTouchedAt: now,
|
|
517
|
+
isFirst: !existing,
|
|
518
|
+
utm: utm || {},
|
|
519
|
+
referrer: referrer || (isBrowser ? document.referrer || null : null),
|
|
520
|
+
serverId: null,
|
|
521
|
+
};
|
|
522
|
+
this._write(fresh);
|
|
523
|
+
return fresh;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
current() { return this._read(); }
|
|
527
|
+
|
|
528
|
+
// Calls /sessions/start on the backend so we get a server-side
|
|
529
|
+
// session row. The local session id is replaced with the server's
|
|
530
|
+
// canonical id so subsequent event payloads can reference it.
|
|
531
|
+
async openServerSession({ device, externalUserId }) {
|
|
532
|
+
const session = this.touch();
|
|
533
|
+
if (session.serverId) return session;
|
|
534
|
+
const res = await this.transport.request("/api/sdk/v1/sessions/start", {
|
|
535
|
+
method: "POST",
|
|
536
|
+
body: {
|
|
537
|
+
device_id: device.device_id,
|
|
538
|
+
external_user_id: externalUserId || undefined,
|
|
539
|
+
referrer: session.referrer,
|
|
540
|
+
utm: session.utm,
|
|
541
|
+
started_at: nowIso(),
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
if (res.ok && res.data?.session?.id) {
|
|
545
|
+
session.serverId = res.data.session.id;
|
|
546
|
+
this._write(session);
|
|
547
|
+
}
|
|
548
|
+
return session;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async end() {
|
|
552
|
+
const session = this._read();
|
|
553
|
+
if (!session?.serverId) { this._clear(); return null; }
|
|
554
|
+
const duration = Date.now() - session.startedAt;
|
|
555
|
+
await this.transport.request("/api/sdk/v1/sessions/end", {
|
|
556
|
+
method: "POST",
|
|
557
|
+
body: { session_id: session.serverId, duration_ms: duration, ended_at: nowIso() },
|
|
558
|
+
}).catch(() => {});
|
|
559
|
+
this._clear();
|
|
560
|
+
return session;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
// ===== src/identity.js =====
|
|
566
|
+
|
|
567
|
+
const IDENTITY_KEY = "identity";
|
|
568
|
+
|
|
569
|
+
// Identity manager — keeps the latest known user identity for this
|
|
570
|
+
// device and pushes it to the backend on change.
|
|
571
|
+
constructor({ storage, transport, logger }) {
|
|
572
|
+
this.storage = storage;
|
|
573
|
+
this.transport = transport;
|
|
574
|
+
this.logger = logger;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
current() { return this.storage.getJson(IDENTITY_KEY, null); }
|
|
578
|
+
|
|
579
|
+
// Idempotent identify call. The backend upserts; the client only
|
|
580
|
+
// POSTs when something has actually changed.
|
|
581
|
+
async identify({ externalUserId, email, phone, attributes }) {
|
|
582
|
+
if (!externalUserId) throw new Error("[nurdd] externalUserId is required to identify");
|
|
583
|
+
const existing = this.current();
|
|
584
|
+
const next = { externalUserId, email: email || null, phone: phone || null, attributes: attributes || {} };
|
|
585
|
+
const unchanged =
|
|
586
|
+
existing &&
|
|
587
|
+
existing.externalUserId === next.externalUserId &&
|
|
588
|
+
existing.email === next.email &&
|
|
589
|
+
existing.phone === next.phone;
|
|
590
|
+
this.storage.setJson(IDENTITY_KEY, next);
|
|
591
|
+
if (unchanged) return { ok: true, identity: existing, cached: true };
|
|
592
|
+
|
|
593
|
+
const res = await this.transport.request("/api/sdk/v1/identities", {
|
|
594
|
+
method: "POST",
|
|
595
|
+
body: {
|
|
596
|
+
external_user_id: externalUserId,
|
|
597
|
+
email: email || undefined,
|
|
598
|
+
phone: phone || undefined,
|
|
599
|
+
attributes: attributes || {},
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
return { ok: res.ok, identity: res.data?.identity || null };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Clears the cached identity. Use on logout. Device id is unchanged
|
|
606
|
+
// so anonymous activity after logout remains stitched to this device.
|
|
607
|
+
reset() {
|
|
608
|
+
this.storage.remove(IDENTITY_KEY);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Server-side merge for "logged-in user was previously this anonymous
|
|
612
|
+
// user" flows.
|
|
613
|
+
async merge({ primaryExternalUserId, secondaryExternalUserId }) {
|
|
614
|
+
return this.transport.request("/api/sdk/v1/identities/merge", {
|
|
615
|
+
method: "POST",
|
|
616
|
+
body: {
|
|
617
|
+
primary_external_user_id: primaryExternalUserId,
|
|
618
|
+
secondary_external_user_id: secondaryExternalUserId,
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
// ===== src/deeplinks.js =====
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
// Deferred deep link resolver. On first launch / first SDK init, asks
|
|
629
|
+
// the backend "did this device just click a short link that hasn't yet
|
|
630
|
+
// been claimed?" If yes, the listener receives the deep link path and
|
|
631
|
+
// the host app routes to it.
|
|
632
|
+
//
|
|
633
|
+
// Resolution sources, in priority order:
|
|
634
|
+
// 1. UTM params on the current URL (short_code present) → server claim
|
|
635
|
+
// 2. Fingerprint match via /links/deferred
|
|
636
|
+
constructor({ transport, logger }) {
|
|
637
|
+
this.transport = transport;
|
|
638
|
+
this.logger = logger;
|
|
639
|
+
this.listeners = new Set();
|
|
640
|
+
this.lastResolved = null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
onResolved(listener) {
|
|
644
|
+
this.listeners.add(listener);
|
|
645
|
+
// Fire late subscribers with the cached value so framework
|
|
646
|
+
// adapters don't miss the first resolution.
|
|
647
|
+
if (this.lastResolved) {
|
|
648
|
+
try { listener(this.lastResolved); } catch (e) { this.logger.warn("deep link listener threw", e); }
|
|
649
|
+
}
|
|
650
|
+
return () => this.listeners.delete(listener);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async resolve({ device }) {
|
|
654
|
+
const params = isBrowser ? readUtmFromLocation() : {};
|
|
655
|
+
let resolved = null;
|
|
656
|
+
|
|
657
|
+
if (params.short_code) {
|
|
658
|
+
const link = await this.transport.request(`/api/sdk/v1/links/${encodeURIComponent(params.short_code)}`);
|
|
659
|
+
if (link.ok && link.data?.link) {
|
|
660
|
+
resolved = {
|
|
661
|
+
short_code: link.data.link.short_code,
|
|
662
|
+
deep_link_path: link.data.link.deep_link_path || null,
|
|
663
|
+
destination_url: link.data.link.destination_url || null,
|
|
664
|
+
utm: link.data.link.utm || {},
|
|
665
|
+
metadata: link.data.link.metadata || {},
|
|
666
|
+
source: "url",
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (!resolved) {
|
|
672
|
+
const res = await this.transport.request("/api/sdk/v1/links/deferred", {
|
|
673
|
+
method: "POST",
|
|
674
|
+
body: {
|
|
675
|
+
short_code: params.short_code || "__resolve__",
|
|
676
|
+
platform: "web",
|
|
677
|
+
device_id: device?.device_id,
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
if (res.ok && res.data?.deferred_deep_link) {
|
|
681
|
+
const d = res.data.deferred_deep_link;
|
|
682
|
+
resolved = {
|
|
683
|
+
short_code: d.short_code,
|
|
684
|
+
deep_link_path: d.deep_link_path,
|
|
685
|
+
destination_url: null,
|
|
686
|
+
utm: {},
|
|
687
|
+
metadata: {},
|
|
688
|
+
source: "deferred",
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (resolved) {
|
|
694
|
+
this.lastResolved = resolved;
|
|
695
|
+
for (const l of this.listeners) {
|
|
696
|
+
try { l(resolved); } catch (e) { this.logger.warn("deep link listener threw", e); }
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return resolved;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
// ===== src/autotrack.js =====
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
// Wires up automatic event tracking that every framework benefits from:
|
|
708
|
+
//
|
|
709
|
+
// - SPA page views: works for React Router, Vue Router, Next.js, Nuxt,
|
|
710
|
+
// Angular, Remix etc. by monkey-patching `history.pushState` and
|
|
711
|
+
// `history.replaceState` and listening to `popstate`.
|
|
712
|
+
// - Outbound clicks: any `<a>` whose href leaves the current origin.
|
|
713
|
+
// - Page unload: flush queued events via the beacon channel.
|
|
714
|
+
//
|
|
715
|
+
// All of this is opt-in via `config.autotrack`.
|
|
716
|
+
if (!isBrowser) return () => {};
|
|
717
|
+
const offFns = [];
|
|
718
|
+
|
|
719
|
+
if (config.autotrack.pageViews !== false) {
|
|
720
|
+
offFns.push(trackPageViews(sdk));
|
|
721
|
+
}
|
|
722
|
+
if (config.autotrack.outboundClicks) {
|
|
723
|
+
offFns.push(trackOutboundClicks(sdk));
|
|
724
|
+
}
|
|
725
|
+
if (config.autotrack.flushOnUnload !== false) {
|
|
726
|
+
offFns.push(flushOnUnload(queue));
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return () => offFns.forEach((fn) => { try { fn(); } catch (e) { logger.warn(e); } });
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function trackPageViews(sdk) {
|
|
733
|
+
const fire = () => {
|
|
734
|
+
sdk.track("page_view", {
|
|
735
|
+
url: window.location.href,
|
|
736
|
+
path: window.location.pathname,
|
|
737
|
+
search: window.location.search,
|
|
738
|
+
title: typeof document !== "undefined" ? document.title : null,
|
|
739
|
+
referrer: typeof document !== "undefined" ? document.referrer || null : null,
|
|
740
|
+
});
|
|
741
|
+
};
|
|
742
|
+
// Initial view.
|
|
743
|
+
fire();
|
|
744
|
+
|
|
745
|
+
const origPush = history.pushState;
|
|
746
|
+
const origReplace = history.replaceState;
|
|
747
|
+
history.pushState = function (...args) {
|
|
748
|
+
const r = origPush.apply(this, args);
|
|
749
|
+
queueMicrotask(fire);
|
|
750
|
+
return r;
|
|
751
|
+
};
|
|
752
|
+
history.replaceState = function (...args) {
|
|
753
|
+
const r = origReplace.apply(this, args);
|
|
754
|
+
queueMicrotask(fire);
|
|
755
|
+
return r;
|
|
756
|
+
};
|
|
757
|
+
window.addEventListener("popstate", fire);
|
|
758
|
+
return () => {
|
|
759
|
+
history.pushState = origPush;
|
|
760
|
+
history.replaceState = origReplace;
|
|
761
|
+
window.removeEventListener("popstate", fire);
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function trackOutboundClicks(sdk) {
|
|
766
|
+
const handler = (e) => {
|
|
767
|
+
const a = e.target?.closest?.("a[href]");
|
|
768
|
+
if (!a) return;
|
|
769
|
+
let host = "";
|
|
770
|
+
try { host = new URL(a.href, window.location.href).host; } catch { return; }
|
|
771
|
+
if (!host || host === window.location.host) return;
|
|
772
|
+
sdk.track("outbound_click", { url: a.href, text: (a.textContent || "").trim().slice(0, 256) });
|
|
773
|
+
};
|
|
774
|
+
document.addEventListener("click", handler, { capture: true });
|
|
775
|
+
return () => document.removeEventListener("click", handler, { capture: true });
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function flushOnUnload(queue) {
|
|
779
|
+
const handler = () => queue.flushBeacon();
|
|
780
|
+
// `pagehide` is preferred over `unload` (works on bfcache).
|
|
781
|
+
window.addEventListener("pagehide", handler);
|
|
782
|
+
window.addEventListener("beforeunload", handler);
|
|
783
|
+
document.addEventListener("visibilitychange", () => {
|
|
784
|
+
if (document.visibilityState === "hidden") queue.flushBeacon();
|
|
785
|
+
});
|
|
786
|
+
return () => {
|
|
787
|
+
window.removeEventListener("pagehide", handler);
|
|
788
|
+
window.removeEventListener("beforeunload", handler);
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
// ===== src/sdk.js =====
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
const DEFAULTS = {
|
|
797
|
+
sdkKey: "",
|
|
798
|
+
baseUrl: "",
|
|
799
|
+
appVersion: null,
|
|
800
|
+
debug: false,
|
|
801
|
+
flushIntervalMs: 5_000,
|
|
802
|
+
maxBatchSize: 50,
|
|
803
|
+
sessionTimeoutMs: 30 * 60_000,
|
|
804
|
+
autotrack: {
|
|
805
|
+
pageViews: true,
|
|
806
|
+
outboundClicks: false,
|
|
807
|
+
flushOnUnload: true,
|
|
808
|
+
},
|
|
809
|
+
stripUtmFromUrl: true,
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
// Persisted campaign code attributed to this browser. Set when a Nurdd
|
|
813
|
+
// link is resolved (from the URL or a deferred match) and stamped onto
|
|
814
|
+
// later conversions so signups/purchases credit the original campaign.
|
|
815
|
+
const ATTRIBUTED_SHORT_CODE_KEY = "attributed_short_code";
|
|
816
|
+
|
|
817
|
+
// Framework-agnostic SDK. A single instance is enough for an entire
|
|
818
|
+
// page / app — `getDefaultSdk()` is provided for the common case but
|
|
819
|
+
// you can also keep multiple instances side by side (multi-tenant).
|
|
820
|
+
constructor(options = {}) {
|
|
821
|
+
this.config = mergeOptions(DEFAULTS, options);
|
|
822
|
+
if (!this.config.sdkKey) throw new Error("[nurdd] sdkKey is required");
|
|
823
|
+
if (!this.config.baseUrl) throw new Error("[nurdd] baseUrl is required");
|
|
824
|
+
|
|
825
|
+
this.logger = createLogger({ enabled: this.config.debug });
|
|
826
|
+
this.storage = createStorage({ prefix: `nurdd:${this.config.sdkKey}:` });
|
|
827
|
+
this.transport = new Transport({
|
|
828
|
+
baseUrl: this.config.baseUrl,
|
|
829
|
+
sdkKey: this.config.sdkKey,
|
|
830
|
+
logger: this.logger,
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
this.device = collectDevice({ storage: this.storage, appVersion: this.config.appVersion });
|
|
834
|
+
this.fingerprint = clientFingerprint(this.device);
|
|
835
|
+
|
|
836
|
+
this.queue = new EventQueue({
|
|
837
|
+
storage: this.storage,
|
|
838
|
+
transport: this.transport,
|
|
839
|
+
logger: this.logger,
|
|
840
|
+
flushIntervalMs: this.config.flushIntervalMs,
|
|
841
|
+
maxBatchSize: this.config.maxBatchSize,
|
|
842
|
+
});
|
|
843
|
+
this.session = new SessionManager({
|
|
844
|
+
storage: this.storage, transport: this.transport, logger: this.logger,
|
|
845
|
+
timeoutMs: this.config.sessionTimeoutMs,
|
|
846
|
+
});
|
|
847
|
+
this.identity = new IdentityManager({
|
|
848
|
+
storage: this.storage, transport: this.transport, logger: this.logger,
|
|
849
|
+
});
|
|
850
|
+
this.deepLinks = new DeepLinkResolver({ transport: this.transport, logger: this.logger });
|
|
851
|
+
|
|
852
|
+
this._initPromise = null;
|
|
853
|
+
this._teardownAutotrack = null;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Idempotent. Safe to call multiple times — only does work once.
|
|
857
|
+
async init() {
|
|
858
|
+
if (this._initPromise) return this._initPromise;
|
|
859
|
+
this._initPromise = (async () => {
|
|
860
|
+
// The /init handshake gives the SDK clock-skew, supported
|
|
861
|
+
// platforms and feature flags. It's also a fast way to
|
|
862
|
+
// validate the SDK key before the first event flies.
|
|
863
|
+
const init = await this.transport.request("/api/sdk/v1/init", { method: "GET" });
|
|
864
|
+
if (!init.ok) {
|
|
865
|
+
this.logger.error("init failed", init.error || init.status);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Capture session, UTM, referrer.
|
|
869
|
+
const utm = isBrowser ? readUtmFromLocation() : {};
|
|
870
|
+
const session = this.session.touch({ utm });
|
|
871
|
+
await this.session.openServerSession({
|
|
872
|
+
device: this.device,
|
|
873
|
+
externalUserId: this.identity.current()?.externalUserId,
|
|
874
|
+
}).catch(() => {});
|
|
875
|
+
|
|
876
|
+
// Persist the campaign code as soon as it's known — from the
|
|
877
|
+
// landing URL immediately, and again if a deferred match resolves
|
|
878
|
+
// — so conversions can attribute even after the URL is stripped.
|
|
879
|
+
if (utm.short_code) this.storage.setString(ATTRIBUTED_SHORT_CODE_KEY, utm.short_code);
|
|
880
|
+
this.deepLinks.onResolved((link) => {
|
|
881
|
+
if (link?.short_code) this.storage.setString(ATTRIBUTED_SHORT_CODE_KEY, link.short_code);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
// Resolve deferred deep link in the background — listeners
|
|
885
|
+
// will be notified if anything matches.
|
|
886
|
+
this.deepLinks.resolve({ device: this.device }).catch(() => {});
|
|
887
|
+
|
|
888
|
+
// Strip UTM so a refresh doesn't double-count.
|
|
889
|
+
if (this.config.stripUtmFromUrl && isBrowser) stripUtmFromUrl();
|
|
890
|
+
|
|
891
|
+
this.queue.start();
|
|
892
|
+
this._teardownAutotrack = setupAutotrack({
|
|
893
|
+
sdk: this, config: this.config, queue: this.queue, logger: this.logger,
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
return { server: init.data, session };
|
|
897
|
+
})();
|
|
898
|
+
return this._initPromise;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// --- Public surface ----------------------------------------------------
|
|
902
|
+
// Every method is safe to call before `init()` resolves; events are
|
|
903
|
+
// queued locally and shipped once the queue's first flush ticks.
|
|
904
|
+
|
|
905
|
+
track(eventName, properties = {}, options = {}) {
|
|
906
|
+
if (!eventName) {
|
|
907
|
+
this.logger.warn("track() called without an event name");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const session = this.session.touch();
|
|
911
|
+
const identity = this.identity.current();
|
|
912
|
+
const event = {
|
|
913
|
+
event_name: eventName,
|
|
914
|
+
event_id: options.eventId || uuid(),
|
|
915
|
+
occurred_at: options.occurredAt || nowIso(),
|
|
916
|
+
platform: "web",
|
|
917
|
+
sdk_version: SDK_VERSION,
|
|
918
|
+
app_version: this.config.appVersion,
|
|
919
|
+
session_id: session?.serverId || null,
|
|
920
|
+
properties,
|
|
921
|
+
device: this.device,
|
|
922
|
+
user: identity ? {
|
|
923
|
+
external_user_id: identity.externalUserId,
|
|
924
|
+
email: identity.email || undefined,
|
|
925
|
+
phone: identity.phone || undefined,
|
|
926
|
+
attributes: identity.attributes || {},
|
|
927
|
+
} : undefined,
|
|
928
|
+
revenue: typeof options.revenue === "number" ? options.revenue : undefined,
|
|
929
|
+
currency: options.currency || undefined,
|
|
930
|
+
attribution_lookup: options.attributionLookup || undefined,
|
|
931
|
+
};
|
|
932
|
+
this.queue.enqueue(event);
|
|
933
|
+
return event;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
page(name, properties = {}) {
|
|
937
|
+
return this.track("page_view", { name: name || null, ...properties });
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
async identify(externalUserId, traits = {}) {
|
|
941
|
+
const result = await this.identity.identify({
|
|
942
|
+
externalUserId,
|
|
943
|
+
email: traits.email,
|
|
944
|
+
phone: traits.phone,
|
|
945
|
+
attributes: traits.attributes || {},
|
|
946
|
+
});
|
|
947
|
+
// Also push a server-side session row if we don't have one yet
|
|
948
|
+
// so the new identity is linked to today's session.
|
|
949
|
+
this.session.openServerSession({
|
|
950
|
+
device: this.device, externalUserId,
|
|
951
|
+
}).catch(() => {});
|
|
952
|
+
return result;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
reset() { this.identity.reset(); }
|
|
956
|
+
|
|
957
|
+
async conversion(conversionType, payload = {}) {
|
|
958
|
+
// Auto-attribute to the campaign this browser came from unless the
|
|
959
|
+
// caller passed an explicit shortCode.
|
|
960
|
+
const shortCode = payload.shortCode || this.attributedShortCode() || undefined;
|
|
961
|
+
const res = await this.transport.request("/api/sdk/v1/conversions", {
|
|
962
|
+
method: "POST",
|
|
963
|
+
body: {
|
|
964
|
+
conversion_type: conversionType,
|
|
965
|
+
value: payload.value,
|
|
966
|
+
currency: payload.currency,
|
|
967
|
+
short_code: shortCode,
|
|
968
|
+
campaign_id: payload.campaignId,
|
|
969
|
+
attribution: payload.attribution,
|
|
970
|
+
fingerprint: this.fingerprint,
|
|
971
|
+
},
|
|
972
|
+
});
|
|
973
|
+
return res.data?.conversion || null;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// The campaign code attributed to this browser, if any.
|
|
977
|
+
attributedShortCode() {
|
|
978
|
+
return this.storage.getString(ATTRIBUTED_SHORT_CODE_KEY);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Shopify helper. Writes the attributed campaign code into the cart as a
|
|
982
|
+
// cart attribute, so it travels with the order through checkout (where
|
|
983
|
+
// the SDK can't run) and can be read by your `orders/create` webhook to
|
|
984
|
+
// attribute the purchase. Storefront-only — call it on the cart/product
|
|
985
|
+
// page, e.g. right before "Add to cart" or on the cart page.
|
|
986
|
+
//
|
|
987
|
+
// A shopper can arrive via several campaigns before buying, so codes
|
|
988
|
+
// ACCUMULATE: the attribute holds a comma-separated, de-duplicated list
|
|
989
|
+
// in first-touch → last-touch order. The checkout pixel / webhook then
|
|
990
|
+
// decide which to credit.
|
|
991
|
+
async attachToShopifyCart({ attributeName = "nurdd_short_code" } = {}) {
|
|
992
|
+
if (!isBrowser) return false;
|
|
993
|
+
const shortCode = this.attributedShortCode();
|
|
994
|
+
if (!shortCode) return false;
|
|
995
|
+
try {
|
|
996
|
+
// Read any codes already on the cart so we append rather than
|
|
997
|
+
// overwrite previous campaigns.
|
|
998
|
+
let codes = [];
|
|
999
|
+
try {
|
|
1000
|
+
const cartRes = await fetch("/cart.js", { headers: { Accept: "application/json" } });
|
|
1001
|
+
if (cartRes.ok) {
|
|
1002
|
+
const cart = await cartRes.json();
|
|
1003
|
+
const raw = cart.attributes && cart.attributes[attributeName];
|
|
1004
|
+
if (raw) codes = String(raw).split(",").map((s) => s.trim()).filter(Boolean);
|
|
1005
|
+
}
|
|
1006
|
+
} catch {
|
|
1007
|
+
// No cart yet / read failed — start a fresh list.
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (codes.includes(shortCode)) return true; // already recorded
|
|
1011
|
+
codes.push(shortCode);
|
|
1012
|
+
|
|
1013
|
+
const res = await fetch("/cart/update.js", {
|
|
1014
|
+
method: "POST",
|
|
1015
|
+
headers: { "Content-Type": "application/json" },
|
|
1016
|
+
body: JSON.stringify({ attributes: { [attributeName]: codes.join(",") } }),
|
|
1017
|
+
});
|
|
1018
|
+
if (!res.ok) this.logger.warn("attachToShopifyCart: cart update failed", res.status);
|
|
1019
|
+
return res.ok;
|
|
1020
|
+
} catch (e) {
|
|
1021
|
+
this.logger.warn("attachToShopifyCart failed", e);
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async click({ shortCode, campaignId, kind = "click", metadata } = {}) {
|
|
1027
|
+
const res = await this.transport.request("/api/sdk/v1/clicks", {
|
|
1028
|
+
method: "POST",
|
|
1029
|
+
body: {
|
|
1030
|
+
short_code: shortCode,
|
|
1031
|
+
campaign_id: campaignId,
|
|
1032
|
+
kind, metadata,
|
|
1033
|
+
platform: "web",
|
|
1034
|
+
},
|
|
1035
|
+
});
|
|
1036
|
+
return res.data?.click || null;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Short-link helpers
|
|
1040
|
+
async createShortLink(payload) {
|
|
1041
|
+
const res = await this.transport.request("/api/sdk/v1/links", {
|
|
1042
|
+
method: "POST",
|
|
1043
|
+
body: payload,
|
|
1044
|
+
});
|
|
1045
|
+
return res.data?.link || null;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async resolveShortLink(code) {
|
|
1049
|
+
const res = await this.transport.request(`/api/sdk/v1/links/${encodeURIComponent(code)}`);
|
|
1050
|
+
return res.data?.link || null;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Deferred deep link API — listen for resolutions from anywhere
|
|
1054
|
+
// in the app. Returns an unsubscribe function.
|
|
1055
|
+
onDeepLink(listener) { return this.deepLinks.onResolved(listener); }
|
|
1056
|
+
|
|
1057
|
+
// Referrals
|
|
1058
|
+
async issueReferralCode(payload) {
|
|
1059
|
+
const res = await this.transport.request("/api/sdk/v1/referrals/codes", {
|
|
1060
|
+
method: "POST", body: payload,
|
|
1061
|
+
});
|
|
1062
|
+
return res.data?.referral_code || null;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async recordReferral(payload) {
|
|
1066
|
+
const res = await this.transport.request("/api/sdk/v1/referrals", {
|
|
1067
|
+
method: "POST", body: payload,
|
|
1068
|
+
});
|
|
1069
|
+
return res.data?.referral || null;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Lifecycle
|
|
1073
|
+
async flush() { return this.queue.flush(); }
|
|
1074
|
+
|
|
1075
|
+
async endSession() { return this.session.end(); }
|
|
1076
|
+
|
|
1077
|
+
destroy() {
|
|
1078
|
+
this.queue.stop();
|
|
1079
|
+
if (typeof this._teardownAutotrack === "function") this._teardownAutotrack();
|
|
1080
|
+
this._teardownAutotrack = null;
|
|
1081
|
+
this._initPromise = null;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Diagnostics
|
|
1085
|
+
info() {
|
|
1086
|
+
return {
|
|
1087
|
+
sdkVersion: SDK_VERSION,
|
|
1088
|
+
device: this.device,
|
|
1089
|
+
fingerprint: this.fingerprint,
|
|
1090
|
+
session: this.session.current(),
|
|
1091
|
+
identity: this.identity.current(),
|
|
1092
|
+
queueSize: this.queue.size(),
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Singleton convenience for the most common case — one SDK per page.
|
|
1098
|
+
let defaultInstance = null;
|
|
1099
|
+
return new NurddSdk(options);
|
|
1100
|
+
}
|
|
1101
|
+
if (defaultInstance) return defaultInstance;
|
|
1102
|
+
defaultInstance = new NurddSdk(options);
|
|
1103
|
+
defaultInstance.init().catch((e) => defaultInstance.logger.error("init failed", e));
|
|
1104
|
+
return defaultInstance;
|
|
1105
|
+
}
|
|
1106
|
+
if (!defaultInstance) {
|
|
1107
|
+
throw new Error("[nurdd] SDK has not been initialised. Call initSdk({ sdkKey, baseUrl }) first.");
|
|
1108
|
+
}
|
|
1109
|
+
return defaultInstance;
|
|
1110
|
+
}
|
|
1111
|
+
defaultInstance = instance;
|
|
1112
|
+
return defaultInstance;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
// ===== src/index.js =====
|
|
1117
|
+
|
|
1118
|
+
// Public entry point — what consumers see by default.
|
|
1119
|
+
|
|
1120
|
+
return {
|
|
1121
|
+
NurddSdk: NurddSdk,
|
|
1122
|
+
createSdk: createSdk,
|
|
1123
|
+
initSdk: initSdk,
|
|
1124
|
+
getSdk: getSdk,
|
|
1125
|
+
hasDefaultSdk: hasDefaultSdk,
|
|
1126
|
+
setDefaultSdk: setDefaultSdk,
|
|
1127
|
+
SDK_VERSION: SDK_VERSION,
|
|
1128
|
+
SDK_NAME: SDK_NAME,
|
|
1129
|
+
isBrowser: isBrowser,
|
|
1130
|
+
UTM_KEYS: UTM_KEYS,
|
|
1131
|
+
};
|
|
1132
|
+
});
|