@noirtrack/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/web.js ADDED
@@ -0,0 +1,271 @@
1
+ /**
2
+ * @noirtrack/sdk/web — browser analytics for frameworks (React, Vue, Svelte, and friends).
3
+ *
4
+ * import { createClient } from '@noirtrack/sdk/web';
5
+ * const noir = createClient({ publicKey: 'pk_live_...', autoPageviews: true });
6
+ * noir.event('signup', { plan: 'pro' });
7
+ *
8
+ * Uses the public key only (browser code can't hold a secret). Safe to import in SSR: when there
9
+ * is no `window`, every method is a no-op and `init`-time tracking is skipped.
10
+ */
11
+ import { resolveEndpoint } from './core/endpoint.js';
12
+ import { uuid } from './core/ids.js';
13
+ import { createIngest } from './core/ingest.js';
14
+ const hasWindow = typeof window !== 'undefined' && typeof document !== 'undefined';
15
+ function getCookie(name) {
16
+ const m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
17
+ return m ? (m.pop() ?? null) : null;
18
+ }
19
+ function setCookie(name, value, days) {
20
+ const d = new Date();
21
+ d.setTime(d.getTime() + days * 86_400_000);
22
+ document.cookie = `${name}=${value};expires=${d.toUTCString()};path=/;SameSite=Lax`;
23
+ }
24
+ function isBot() {
25
+ try {
26
+ if (navigator.webdriver)
27
+ return true;
28
+ const ua = (navigator.userAgent || '').toLowerCase();
29
+ if (!ua || ua.length < 5)
30
+ return true;
31
+ return /headless|phantom|selenium|puppeteer|playwright|webdriver|bot|crawl|spider|curl|wget|python|axios|postman/.test(ua);
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ function scrollPercent() {
38
+ try {
39
+ const h = document.documentElement;
40
+ const max = h.scrollHeight - h.clientHeight;
41
+ return max > 0 ? Math.min(100, Math.round(((h.scrollTop || window.pageYOffset || 0) / max) * 100)) : 100;
42
+ }
43
+ catch {
44
+ return 0;
45
+ }
46
+ }
47
+ /** `data-noir-goal-plan="pro"` → `{ plan: "pro" }` (kebab-case suffix → snake_case key). */
48
+ function metaFromAttrs(el, prefix) {
49
+ let meta;
50
+ for (const attr of Array.from(el.attributes)) {
51
+ if (attr.name.startsWith(prefix) && attr.name.length > prefix.length) {
52
+ (meta ??= {})[attr.name.slice(prefix.length).replace(/-/g, '_')] = attr.value;
53
+ }
54
+ }
55
+ return meta;
56
+ }
57
+ /**
58
+ * Declarative goals, matching the hosted snippet so markup is portable between snippet and SDK:
59
+ * <button data-noir-goal="cta" data-noir-goal-loc="hero"> — fires on click (event delegation)
60
+ * <section data-noir-scroll="viewed_pricing"> — fires once when scrolled into view
61
+ * Click uses delegation so dynamically-rendered elements work; scroll targets are (re-)scanned via a
62
+ * MutationObserver because a framework mounts them after createClient() runs.
63
+ */
64
+ function initDeclarativeGoals(fire) {
65
+ document.addEventListener('click', (e) => {
66
+ const el = e.target?.closest?.('[data-noir-goal]');
67
+ const name = el?.getAttribute('data-noir-goal');
68
+ if (el && name)
69
+ fire(name, metaFromAttrs(el, 'data-noir-goal-'));
70
+ }, true);
71
+ if (!('IntersectionObserver' in window))
72
+ return;
73
+ const observed = new WeakSet();
74
+ const observeScroll = (el) => {
75
+ if (observed.has(el))
76
+ return;
77
+ observed.add(el);
78
+ const name = el.getAttribute('data-noir-scroll');
79
+ if (!name)
80
+ return;
81
+ let threshold = parseFloat(el.getAttribute('data-noir-scroll-threshold') ?? '');
82
+ if (!(threshold > 0 && threshold <= 1))
83
+ threshold = 0.5;
84
+ const delay = parseInt(el.getAttribute('data-noir-scroll-delay') ?? '', 10) || 0;
85
+ const obs = new IntersectionObserver((entries) => {
86
+ for (const entry of entries) {
87
+ if (entry.isIntersecting && entry.intersectionRatio >= threshold) {
88
+ obs.disconnect(); // once per element per page
89
+ const meta = { scroll_percentage: scrollPercent(), threshold };
90
+ if (delay > 0)
91
+ setTimeout(() => fire(name, meta), delay);
92
+ else
93
+ fire(name, meta);
94
+ return;
95
+ }
96
+ }
97
+ }, { threshold });
98
+ obs.observe(el);
99
+ };
100
+ const scan = () => document.querySelectorAll('[data-noir-scroll]').forEach(observeScroll);
101
+ scan();
102
+ if (window.MutationObserver) {
103
+ let queued = false;
104
+ new MutationObserver(() => {
105
+ if (queued)
106
+ return;
107
+ queued = true;
108
+ setTimeout(() => {
109
+ queued = false;
110
+ scan();
111
+ }, 200);
112
+ }).observe(document.documentElement, { childList: true, subtree: true });
113
+ }
114
+ }
115
+ /** A no-op client for SSR (no window) so imports and calls are always safe. */
116
+ function noopClient(cookieless) {
117
+ const noop = () => { };
118
+ return {
119
+ view: noop,
120
+ event: noop,
121
+ identify: noop,
122
+ revenue: noop,
123
+ reset: noop,
124
+ flush: noop,
125
+ ping: noop,
126
+ links: { params: () => ({ noir_vid: '', noir_sid: '' }), decorate: (url) => url },
127
+ shield: async () => ({ ok: true, reason: null }),
128
+ check: async () => null,
129
+ cookieless,
130
+ };
131
+ }
132
+ export function createClient(options) {
133
+ const cookieless = options.cookieless ?? false;
134
+ if (!hasWindow)
135
+ return noopClient(cookieless);
136
+ const endpoint = resolveEndpoint(options.endpoint);
137
+ const blocked = isBot() || (!options.allowLocalhost && /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname)) || window.top !== window.self;
138
+ // Visitor + session ids. With cookies: persistent vid (365d) + short sid (30m). Cookieless:
139
+ // sessionStorage only, so nothing is written to the device.
140
+ function id(kind) {
141
+ const cookieName = kind === 'vid' ? 'noir_vid' : 'noir_sid';
142
+ if (cookieless) {
143
+ try {
144
+ let v = sessionStorage.getItem(cookieName);
145
+ if (!v) {
146
+ v = uuid();
147
+ sessionStorage.setItem(cookieName, v);
148
+ }
149
+ return v;
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ }
155
+ let v = getCookie(cookieName);
156
+ if (!v) {
157
+ v = uuid();
158
+ setCookie(cookieName, v, kind === 'vid' ? 365 : 1 / 48);
159
+ }
160
+ return v;
161
+ }
162
+ function context(pathOverride) {
163
+ const params = new URLSearchParams(location.search);
164
+ return {
165
+ host: location.hostname,
166
+ path: pathOverride ?? location.pathname + location.search,
167
+ referrer: document.referrer || null,
168
+ screen: window.screen ? `${screen.width}x${screen.height}` : null,
169
+ utm: {
170
+ source: params.get('utm_source'),
171
+ medium: params.get('utm_medium'),
172
+ campaign: params.get('utm_campaign'),
173
+ },
174
+ };
175
+ }
176
+ const platform = {
177
+ publicKey: options.publicKey,
178
+ endpoint,
179
+ timeoutMs: options.timeoutMs ?? 800,
180
+ batch: true,
181
+ flushIntervalMs: options.flushIntervalMs ?? 5000,
182
+ maxQueueSize: options.maxQueueSize ?? 10,
183
+ visitorId: () => id('vid'),
184
+ sessionId: () => id('sid'),
185
+ resetIds() {
186
+ if (cookieless) {
187
+ try {
188
+ sessionStorage.removeItem('noir_vid');
189
+ sessionStorage.removeItem('noir_sid');
190
+ }
191
+ catch {
192
+ /* ignore */
193
+ }
194
+ }
195
+ else {
196
+ setCookie('noir_vid', '', -1);
197
+ setCookie('noir_sid', '', -1);
198
+ }
199
+ },
200
+ context,
201
+ disabled: () => blocked,
202
+ deliver(url, body) {
203
+ const json = JSON.stringify(body);
204
+ if (navigator.sendBeacon) {
205
+ navigator.sendBeacon(url, new Blob([json], { type: 'application/json' }));
206
+ }
207
+ else {
208
+ void fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: json, keepalive: true }).catch(() => { });
209
+ }
210
+ },
211
+ scheduleFlush(flush) {
212
+ setInterval(flush, options.flushIntervalMs ?? 5000);
213
+ const final = () => {
214
+ if (document.visibilityState === 'hidden')
215
+ flush();
216
+ };
217
+ document.addEventListener('visibilitychange', final);
218
+ window.addEventListener('pagehide', flush);
219
+ },
220
+ };
221
+ const ingest = createIngest(platform);
222
+ const client = { ...ingest, cookieless };
223
+ if (!blocked) {
224
+ // Soft block on the first view: ask the server, then enforce. /api/v1/check already RECORDS
225
+ // this pageview (stamped with the verdict), so when blocking is on we must NOT also call
226
+ // ingest.view() for the first view, or it would be counted twice.
227
+ const wantsBlock = !!options.block;
228
+ if (wantsBlock) {
229
+ void ingest.check().then((verdict) => {
230
+ if (verdict?.action !== 'block')
231
+ return;
232
+ const page = verdict.blocked_page ?? options.blockedPage;
233
+ if (options.block === 'redirect' && page)
234
+ location.replace(page);
235
+ else
236
+ overlay();
237
+ });
238
+ }
239
+ if (options.autoPageviews ?? true) {
240
+ if (!wantsBlock)
241
+ ingest.view(); // when blocking, check() already recorded the first view
242
+ const onRoute = () => ingest.view();
243
+ const push = history.pushState;
244
+ history.pushState = function (...args) {
245
+ push.apply(this, args);
246
+ onRoute();
247
+ };
248
+ window.addEventListener('popstate', onRoute);
249
+ }
250
+ // Declarative goals (data-noir-goal / data-noir-scroll) — parity with the hosted snippet.
251
+ if (options.autoGoals ?? true) {
252
+ initDeclarativeGoals((name, meta) => ingest.event(name, meta));
253
+ }
254
+ // Presence heartbeat — keep this visitor in the realtime count while the tab is visible
255
+ // (parity with the hosted snippet). Paused when hidden; re-sent the instant it regains focus.
256
+ const heartbeat = () => {
257
+ if (document.visibilityState === 'visible')
258
+ ingest.ping();
259
+ };
260
+ document.addEventListener('visibilitychange', heartbeat);
261
+ setInterval(heartbeat, 45_000);
262
+ }
263
+ return client;
264
+ }
265
+ function overlay() {
266
+ const el = document.createElement('div');
267
+ el.setAttribute('style', 'position:fixed;inset:0;z-index:2147483647;background:#0a0a0a;color:#fff;' +
268
+ 'display:flex;align-items:center;justify-content:center;font:600 16px system-ui;padding:2rem;text-align:center');
269
+ el.textContent = 'Access blocked';
270
+ document.documentElement.appendChild(el);
271
+ }
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@noirtrack/sdk",
3
+ "version": "0.1.0",
4
+ "description": "NoirTrack server SDK. Block bots and bad traffic before your app renders, and record goals, revenue, and identify from your backend with one secret key. Framework-agnostic core plus Next.js, Express, and fetch/edge adapters.",
5
+ "homepage": "https://noirtrack.com/docs",
6
+ "bugs": {
7
+ "url": "https://noirtrack.com/docs"
8
+ },
9
+ "author": "NoirTrack",
10
+ "type": "module",
11
+ "main": "dist/index.js",
12
+ "module": "dist/index.js",
13
+ "types": "dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ },
19
+ "./web": {
20
+ "types": "./dist/web.d.ts",
21
+ "import": "./dist/web.js"
22
+ },
23
+ "./react-native": {
24
+ "types": "./dist/react-native.d.ts",
25
+ "import": "./dist/react-native.js"
26
+ },
27
+ "./next": {
28
+ "types": "./dist/next.d.ts",
29
+ "import": "./dist/next.js"
30
+ },
31
+ "./express": {
32
+ "types": "./dist/express.d.ts",
33
+ "import": "./dist/express.js"
34
+ },
35
+ "./fetch": {
36
+ "types": "./dist/fetch.d.ts",
37
+ "import": "./dist/fetch.js"
38
+ },
39
+ "./form-shield": {
40
+ "types": "./dist/form-shield.d.ts",
41
+ "import": "./dist/form-shield.js"
42
+ }
43
+ },
44
+ "files": ["dist"],
45
+ "scripts": {
46
+ "build": "tsc",
47
+ "test:live": "node ../docs/live-test.mjs"
48
+ },
49
+ "keywords": ["noirtrack", "firewall", "bot-detection", "waf", "revenue", "goals", "middleware", "nextjs", "express", "edge"],
50
+ "license": "MIT",
51
+ "engines": {
52
+ "node": ">=18"
53
+ },
54
+ "peerDependencies": {
55
+ "express": ">=4",
56
+ "next": ">=13"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "express": { "optional": true },
60
+ "next": { "optional": true }
61
+ },
62
+ "devDependencies": {
63
+ "@types/express": "^4.17.21",
64
+ "express": "^4.19.0",
65
+ "next": "^14.0.0",
66
+ "typescript": "^5.4.0"
67
+ }
68
+ }