@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,105 @@
1
+ // Network transport. Designed around three constraints:
2
+ // 1. The page may close mid-request (unload) — we use `sendBeacon` /
3
+ // `keepalive: true` whenever possible so events still ship.
4
+ // 2. The network may be flaky — retries use exponential backoff with
5
+ // jitter; 5xx and network errors are retried, 4xx are not.
6
+ // 3. We never throw into the host app — every public call resolves with
7
+ // an { ok, status, data, error } shape.
8
+
9
+ const RETRY_STATUSES = new Set([0, 408, 425, 429, 500, 502, 503, 504]);
10
+
11
+ export class Transport {
12
+ constructor({ baseUrl, sdkKey, logger, maxRetries = 4, baseRetryDelayMs = 250, timeoutMs = 15_000 }) {
13
+ if (!baseUrl) throw new Error("[nurdd] baseUrl is required");
14
+ if (!sdkKey) throw new Error("[nurdd] sdkKey is required");
15
+ this.baseUrl = baseUrl.replace(/\/$/, "");
16
+ this.sdkKey = sdkKey;
17
+ this.logger = logger;
18
+ this.maxRetries = maxRetries;
19
+ this.baseRetryDelayMs = baseRetryDelayMs;
20
+ this.timeoutMs = timeoutMs;
21
+ }
22
+
23
+ _headers() {
24
+ return {
25
+ "Content-Type": "application/json",
26
+ "X-SDK-Key": this.sdkKey,
27
+ "X-SDK-Platform": "web",
28
+ };
29
+ }
30
+
31
+ _backoff(attempt) {
32
+ const jitter = Math.random() * this.baseRetryDelayMs;
33
+ return this.baseRetryDelayMs * 2 ** attempt + jitter;
34
+ }
35
+
36
+ async request(path, { method = "GET", body, beacon = false, signal } = {}) {
37
+ const url = `${this.baseUrl}${path}`;
38
+ const init = {
39
+ method,
40
+ headers: this._headers(),
41
+ // `keepalive` lets fetch survive page navigation. Capped at 64KB.
42
+ keepalive: beacon === true || method !== "GET",
43
+ body: body !== undefined ? JSON.stringify(body) : undefined,
44
+ signal,
45
+ };
46
+
47
+ let lastError = null;
48
+ for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
49
+ try {
50
+ const ac = new AbortController();
51
+ const timeoutId = setTimeout(() => ac.abort(), this.timeoutMs);
52
+ // Combine the consumer-provided signal with our own timeout.
53
+ if (signal) signal.addEventListener("abort", () => ac.abort(), { once: true });
54
+
55
+ const res = await fetch(url, { ...init, signal: ac.signal });
56
+ clearTimeout(timeoutId);
57
+
58
+ if (res.ok) {
59
+ const data = await this._parseBody(res);
60
+ return { ok: true, status: res.status, data };
61
+ }
62
+ if (!RETRY_STATUSES.has(res.status) || attempt === this.maxRetries) {
63
+ const data = await this._parseBody(res);
64
+ return { ok: false, status: res.status, data, error: data?.error || `HTTP ${res.status}` };
65
+ }
66
+ lastError = new Error(`HTTP ${res.status}`);
67
+ } catch (err) {
68
+ lastError = err;
69
+ if (attempt === this.maxRetries) {
70
+ return { ok: false, status: 0, error: err?.message || "Network error" };
71
+ }
72
+ }
73
+ await new Promise((r) => setTimeout(r, this._backoff(attempt)));
74
+ }
75
+ return { ok: false, status: 0, error: lastError?.message || "Unknown error" };
76
+ }
77
+
78
+ async _parseBody(res) {
79
+ const text = await res.text();
80
+ if (!text) return null;
81
+ try { return JSON.parse(text); } catch { return text; }
82
+ }
83
+
84
+ // Fire-and-forget delivery used during page unload. Falls back to
85
+ // a keepalive fetch when sendBeacon isn't available.
86
+ deliverBeacon(path, body) {
87
+ const url = `${this.baseUrl}${path}`;
88
+ const payload = body ? JSON.stringify(body) : "{}";
89
+ try {
90
+ if (typeof navigator !== "undefined" && navigator.sendBeacon) {
91
+ // Beacon requires Content-Type via the Blob constructor.
92
+ const blob = new Blob([payload], { type: "application/json" });
93
+ // Pass auth as a query string so beacon (which can't set headers) still authenticates.
94
+ const sep = url.includes("?") ? "&" : "?";
95
+ return navigator.sendBeacon(`${url}${sep}sdk_key=${encodeURIComponent(this.sdkKey)}`, blob);
96
+ }
97
+ } catch { /* fall through to fetch */ }
98
+ try {
99
+ fetch(url, {
100
+ method: "POST", headers: this._headers(), body: payload, keepalive: true,
101
+ }).catch(() => {});
102
+ return true;
103
+ } catch { return false; }
104
+ }
105
+ }
package/src/utils.js ADDED
@@ -0,0 +1,56 @@
1
+ // Tiny helpers used across the SDK. No dependencies on env so they're
2
+ // safe to import from SSR contexts.
3
+
4
+ export function uuid() {
5
+ // Prefer the platform impl when available — it's cryptographically random.
6
+ if (typeof globalThis.crypto?.randomUUID === "function") {
7
+ return globalThis.crypto.randomUUID();
8
+ }
9
+ // RFC 4122 v4-ish fallback. Fine for device IDs / event IDs.
10
+ const rnd = (n) => Math.floor(Math.random() * n);
11
+ const seg = (n) => Array.from({ length: n }, () => rnd(16).toString(16)).join("");
12
+ return `${seg(8)}-${seg(4)}-4${seg(3)}-${(8 + rnd(4)).toString(16)}${seg(3)}-${seg(12)}`;
13
+ }
14
+
15
+ export function nowIso() { return new Date().toISOString(); }
16
+
17
+ export function safeJsonParse(value, fallback = null) {
18
+ try { return JSON.parse(value); } catch { return fallback; }
19
+ }
20
+
21
+ // Stable hash, ASCII-friendly. Not cryptographic — used only for client
22
+ // fingerprint hints; the backend computes the canonical fingerprint.
23
+ export function fnv1a(str) {
24
+ let h = 2166136261 >>> 0;
25
+ for (let i = 0; i < str.length; i += 1) {
26
+ h ^= str.charCodeAt(i);
27
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
28
+ }
29
+ return h.toString(16).padStart(8, "0");
30
+ }
31
+
32
+ export function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
33
+
34
+ export function debounce(fn, wait) {
35
+ let t = null;
36
+ return (...args) => {
37
+ if (t) clearTimeout(t);
38
+ t = setTimeout(() => fn(...args), wait);
39
+ };
40
+ }
41
+
42
+ // Merges options with a defaults object, allowing nested objects but not
43
+ // arrays (consumer arrays always replace defaults — fewer surprises).
44
+ export function mergeOptions(defaults, overrides) {
45
+ if (!overrides) return { ...defaults };
46
+ const out = { ...defaults };
47
+ for (const [k, v] of Object.entries(overrides)) {
48
+ if (v === undefined) continue;
49
+ if (v && typeof v === "object" && !Array.isArray(v) && defaults[k] && typeof defaults[k] === "object") {
50
+ out[k] = { ...defaults[k], ...v };
51
+ } else {
52
+ out[k] = v;
53
+ }
54
+ }
55
+ return out;
56
+ }
package/src/utm.js ADDED
@@ -0,0 +1,46 @@
1
+ import { isBrowser } from "./env.js";
2
+
3
+ export const UTM_KEYS = [
4
+ "utm_source",
5
+ "utm_medium",
6
+ "utm_campaign",
7
+ "utm_term",
8
+ "utm_content",
9
+ "utm_id",
10
+ ];
11
+
12
+ // Reads UTM params and a few common attribution params from `location.search`.
13
+ // Stable across SPA navigations because the SDK re-reads on every page view.
14
+ export function readUtmFromLocation() {
15
+ if (!isBrowser) return {};
16
+ try {
17
+ const params = new URLSearchParams(window.location.search);
18
+ const out = {};
19
+ for (const key of UTM_KEYS) {
20
+ const v = params.get(key);
21
+ if (v) out[key] = v;
22
+ }
23
+ // Auxiliary identifiers we commonly want to attribute on.
24
+ const short = params.get("sdk_short_code") || params.get("nurdd_shortcode");
25
+ if (short) out.short_code = short;
26
+ const click = params.get("click_id") || params.get("gclid") || params.get("fbclid");
27
+ if (click) out.click_id = click;
28
+ return out;
29
+ } catch {
30
+ return {};
31
+ }
32
+ }
33
+
34
+ // Strips UTM params from the current URL so a single visit doesn't get
35
+ // counted on every subsequent page view. Best-effort: requires history API.
36
+ export function stripUtmFromUrl() {
37
+ if (!isBrowser || !window.history?.replaceState) return;
38
+ try {
39
+ const url = new URL(window.location.href);
40
+ let changed = false;
41
+ for (const k of UTM_KEYS) {
42
+ if (url.searchParams.has(k)) { url.searchParams.delete(k); changed = true; }
43
+ }
44
+ if (changed) window.history.replaceState({}, "", url.toString());
45
+ } catch { /* ignore */ }
46
+ }
@@ -0,0 +1,30 @@
1
+ import type { InjectionToken } from "@angular/core";
2
+ import type {
3
+ NurddSdk,
4
+ SdkConfig,
5
+ IdentityTraits,
6
+ TrackOptions,
7
+ ConversionPayload,
8
+ ClickPayload,
9
+ DeepLinkResolution,
10
+ } from "./index";
11
+
12
+ export const NURDD_SDK: InjectionToken<NurddSdk>;
13
+
14
+ export class NurddService {
15
+ sdk: NurddSdk;
16
+ constructor(sdk: NurddSdk);
17
+ track(event: string, properties?: Record<string, unknown>, options?: TrackOptions): unknown;
18
+ page(name?: string, properties?: Record<string, unknown>): unknown;
19
+ identify(externalUserId: string, traits?: IdentityTraits): Promise<unknown>;
20
+ reset(): void;
21
+ conversion(type: string, payload?: ConversionPayload): Promise<unknown>;
22
+ click(payload?: ClickPayload): Promise<unknown>;
23
+ onDeepLink(listener: (payload: DeepLinkResolution) => void): () => void;
24
+ flush(): Promise<unknown>;
25
+ raw(): NurddSdk;
26
+ }
27
+
28
+ export function provideNurdd(config: SdkConfig): unknown[];
29
+
30
+ export { createSdk, initSdk, getSdk, setDefaultSdk } from "./index";
@@ -0,0 +1,172 @@
1
+ // TypeScript declarations for @nurdd/web-sdk.
2
+
3
+ export const SDK_VERSION: string;
4
+ export const SDK_NAME: string;
5
+ export const isBrowser: boolean;
6
+ export const UTM_KEYS: readonly string[];
7
+
8
+ export type Platform = "web" | "android" | "ios" | "flutter" | "react_native" | "server" | "unknown";
9
+
10
+ export interface SdkConfig {
11
+ /** Public SDK key issued by your backend (POST /api/sdk/v1/apps). */
12
+ sdkKey: string;
13
+ /** Origin of your attribution backend, e.g. https://api.example.com */
14
+ baseUrl: string;
15
+ /** Optional app version forwarded with every event. */
16
+ appVersion?: string | null;
17
+ /** Enables verbose console logging. */
18
+ debug?: boolean;
19
+ /** Background flush interval for the offline event queue. */
20
+ flushIntervalMs?: number;
21
+ /** Max events per batched POST. */
22
+ maxBatchSize?: number;
23
+ /** Idle session timeout in ms (default: 30min). */
24
+ sessionTimeoutMs?: number;
25
+ /** Auto-tracking toggles. */
26
+ autotrack?: {
27
+ pageViews?: boolean;
28
+ outboundClicks?: boolean;
29
+ flushOnUnload?: boolean;
30
+ };
31
+ /** Strip UTM params from the URL after the first read (default: true). */
32
+ stripUtmFromUrl?: boolean;
33
+ }
34
+
35
+ export interface DeviceInfo {
36
+ device_id: string;
37
+ platform: Platform;
38
+ os_name?: string | null;
39
+ os_version?: string | null;
40
+ model?: string | null;
41
+ manufacturer?: string | null;
42
+ app_version?: string | null;
43
+ sdk_version?: string;
44
+ locale?: string | null;
45
+ timezone?: string | null;
46
+ attributes?: Record<string, unknown>;
47
+ }
48
+
49
+ export interface IdentityTraits {
50
+ email?: string;
51
+ phone?: string;
52
+ attributes?: Record<string, unknown>;
53
+ }
54
+
55
+ export interface Identity {
56
+ externalUserId: string;
57
+ email?: string | null;
58
+ phone?: string | null;
59
+ attributes?: Record<string, unknown>;
60
+ }
61
+
62
+ export interface TrackOptions {
63
+ eventId?: string;
64
+ occurredAt?: string;
65
+ revenue?: number;
66
+ currency?: string;
67
+ attributionLookup?: boolean;
68
+ }
69
+
70
+ export interface Session {
71
+ id: string;
72
+ startedAt: number;
73
+ lastTouchedAt: number;
74
+ isFirst: boolean;
75
+ utm: Record<string, string>;
76
+ referrer: string | null;
77
+ serverId: string | null;
78
+ }
79
+
80
+ export interface ConversionPayload {
81
+ value?: number;
82
+ currency?: string;
83
+ shortCode?: string;
84
+ campaignId?: string;
85
+ attribution?: Record<string, unknown>;
86
+ }
87
+
88
+ export interface ClickPayload {
89
+ shortCode?: string;
90
+ campaignId?: string;
91
+ kind?: "click" | "impression";
92
+ metadata?: Record<string, unknown>;
93
+ }
94
+
95
+ export interface DeepLinkResolution {
96
+ short_code: string;
97
+ deep_link_path: string | null;
98
+ destination_url: string | null;
99
+ utm: Record<string, string>;
100
+ metadata: Record<string, unknown>;
101
+ source: "url" | "deferred";
102
+ }
103
+
104
+ export interface ShortLinkPayload {
105
+ destination_url: string;
106
+ short_code?: string;
107
+ campaign_id?: string;
108
+ ios_url?: string;
109
+ android_url?: string;
110
+ web_url?: string;
111
+ fallback_url?: string;
112
+ deep_link_path?: string;
113
+ utm?: Record<string, string>;
114
+ metadata?: Record<string, unknown>;
115
+ expires_at?: string;
116
+ }
117
+
118
+ export interface ReferralCodePayload {
119
+ owner_identity_id: string;
120
+ destination_url?: string;
121
+ metadata?: Record<string, unknown>;
122
+ }
123
+
124
+ export interface ReferralRecordPayload {
125
+ code: string;
126
+ referred_device_uuid?: string;
127
+ referred_identity_id?: string;
128
+ }
129
+
130
+ export interface FlushResult {
131
+ ok: boolean;
132
+ count?: number;
133
+ error?: string;
134
+ skipped?: boolean;
135
+ }
136
+
137
+ export interface SdkInfo {
138
+ sdkVersion: string;
139
+ device: DeviceInfo;
140
+ fingerprint: string;
141
+ session: Session | null;
142
+ identity: Identity | null;
143
+ queueSize: number;
144
+ }
145
+
146
+ export class NurddSdk {
147
+ constructor(options: SdkConfig);
148
+ readonly device: DeviceInfo;
149
+ readonly fingerprint: string;
150
+ init(): Promise<{ server: unknown; session: Session }>;
151
+ track(eventName: string, properties?: Record<string, unknown>, options?: TrackOptions): unknown;
152
+ page(name?: string, properties?: Record<string, unknown>): unknown;
153
+ identify(externalUserId: string, traits?: IdentityTraits): Promise<{ ok: boolean; identity: Identity | null; cached?: boolean }>;
154
+ reset(): void;
155
+ conversion(conversionType: string, payload?: ConversionPayload): Promise<unknown>;
156
+ click(payload?: ClickPayload): Promise<unknown>;
157
+ createShortLink(payload: ShortLinkPayload): Promise<unknown>;
158
+ resolveShortLink(code: string): Promise<unknown>;
159
+ onDeepLink(listener: (payload: DeepLinkResolution) => void): () => void;
160
+ issueReferralCode(payload: ReferralCodePayload): Promise<unknown>;
161
+ recordReferral(payload: ReferralRecordPayload): Promise<unknown>;
162
+ flush(): Promise<FlushResult>;
163
+ endSession(): Promise<Session | null>;
164
+ destroy(): void;
165
+ info(): SdkInfo;
166
+ }
167
+
168
+ export function createSdk(options: SdkConfig): NurddSdk;
169
+ export function initSdk(options: SdkConfig): NurddSdk;
170
+ export function getSdk(): NurddSdk;
171
+ export function hasDefaultSdk(): boolean;
172
+ export function setDefaultSdk(instance: NurddSdk): NurddSdk;
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface NurddPageViewProps {
4
+ pathname: string;
5
+ searchParams?: { toString(): string } | null;
6
+ properties?: Record<string, unknown>;
7
+ }
8
+
9
+ export function NurddPageView(props: NurddPageViewProps): JSX.Element | null;
10
+ export * from "./react";
@@ -0,0 +1,5 @@
1
+ // Nuxt plugin default export. Nuxt expects a function returned by
2
+ // `defineNuxtPlugin`; the runtime accepts any signature.
3
+ declare const plugin: unknown;
4
+ export default plugin;
5
+ export * from "./vue";
@@ -0,0 +1,28 @@
1
+ import type { ReactNode } from "react";
2
+ import type {
3
+ NurddSdk,
4
+ SdkConfig,
5
+ Identity,
6
+ IdentityTraits,
7
+ TrackOptions,
8
+ DeepLinkResolution,
9
+ } from "./index";
10
+
11
+ export interface NurddProviderProps {
12
+ config?: SdkConfig;
13
+ sdk?: NurddSdk;
14
+ children?: ReactNode;
15
+ }
16
+
17
+ export function NurddProvider(props: NurddProviderProps): JSX.Element;
18
+ export function useNurdd(): NurddSdk;
19
+ export function useTrack(): (event: string, properties?: Record<string, unknown>, options?: TrackOptions) => unknown;
20
+ export function usePageView(path: string | null | undefined, properties?: Record<string, unknown>): void;
21
+ export function useDeepLink(callback: (payload: DeepLinkResolution) => void): void;
22
+ export function useIdentity(): {
23
+ identity: Identity | null;
24
+ identify: (externalUserId: string, traits?: IdentityTraits) => Promise<unknown>;
25
+ reset: () => void;
26
+ };
27
+
28
+ export { createSdk, initSdk, getSdk, setDefaultSdk } from "./index";
package/types/vue.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { App, Ref } from "vue";
2
+ import type { NurddSdk, SdkConfig, Identity, IdentityTraits, DeepLinkResolution } from "./index";
3
+
4
+ export interface NurddPluginOptions extends Partial<SdkConfig> {
5
+ config?: SdkConfig;
6
+ sdk?: NurddSdk;
7
+ sdkKey?: string;
8
+ baseUrl?: string;
9
+ }
10
+
11
+ export const NurddPlugin: { install(app: App, options: NurddPluginOptions): void };
12
+ export function useNurdd(): NurddSdk;
13
+ export function trackPageViews(routeRef: Ref<{ fullPath?: string; path?: string; name?: unknown; params?: unknown; query?: unknown }>): void;
14
+ export function useDeepLink(callback: (payload: DeepLinkResolution) => void): void;
15
+ export function useIdentity(): {
16
+ identity: Ref<Identity | null>;
17
+ identify: (externalUserId: string, traits?: IdentityTraits) => Promise<unknown>;
18
+ reset: () => void;
19
+ };
20
+
21
+ export { createSdk, initSdk, getSdk, setDefaultSdk } from "./index";