@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.
@@ -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
+ });