@nexly/web 0.14.0 → 0.15.1

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 CHANGED
@@ -67,3 +67,7 @@ await fetch('/api/link-visitor', {
67
67
  `getVisitorId()` reads the persistent anonymous id Nexly stores in `localStorage`; `getSessionId()` reads the 30-minute tab session id from `sessionStorage`. Both are plain strings, safe to call from anywhere.
68
68
 
69
69
  See the full recipe (backend storage + emitting linked events) in the [`@nexly/node` README](https://www.npmjs.com/package/@nexly/node).
70
+
71
+ ## Bot detection
72
+
73
+ Bots and AI crawlers are classified server-side from the HTTP `User-Agent` header at ingest — do **not** try to compute or send an `is_bot` flag from your page code. Every event is stored (bots are not filtered out); the dashboard surfaces them in the dedicated "AI & Crawler activity" section and excludes them from standard visitor metrics.
@@ -1,3 +1,4 @@
1
1
  export { startEngagementTracking } from './engagement.js';
2
+ export { startOutboundLinkTracking } from './outbound-links.js';
2
3
  export { getDefaultPathname, sendPageViewBeacon } from './pageview.js';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/events/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/events/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAA;AAC/D,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA"}
@@ -1,2 +1,3 @@
1
1
  export { startEngagementTracking } from './engagement.js';
2
+ export { startOutboundLinkTracking } from './outbound-links.js';
2
3
  export { getDefaultPathname, sendPageViewBeacon } from './pageview.js';
@@ -0,0 +1,17 @@
1
+ import type { CollectCredentials } from '@nexly/core';
2
+ type StopFn = () => void;
3
+ /**
4
+ * Attaches a delegated listener that auto-tracks four navigation cases on
5
+ * `<a>` elements:
6
+ * - external `http(s)` links — `outbound_link_clicked`
7
+ * - file downloads (by `download` attr or known extension) — `file_downloaded`
8
+ * - `mailto:` links — `mailto_clicked`
9
+ * - `tel:` links — `tel_clicked`
10
+ *
11
+ * All events use `type: 'navigation'`. Listens to both `click` and `auxclick`
12
+ * (middle-click). Skips elements (or their ancestors) marked with
13
+ * `[data-nexly-ignore]`. Returns a cleanup function.
14
+ */
15
+ export declare function startOutboundLinkTracking(creds: CollectCredentials): StopFn;
16
+ export {};
17
+ //# sourceMappingURL=outbound-links.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbound-links.d.ts","sourceRoot":"","sources":["../../src/events/outbound-links.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAkKrD,KAAK,MAAM,GAAG,MAAM,IAAI,CAAA;AAExB;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,kBAAkB,GAAG,MAAM,CA0C3E"}
@@ -0,0 +1,188 @@
1
+ import { sendBeaconCollect } from '../beacon.js';
2
+ import { collectEventMeta } from '../browser-meta.js';
3
+ /**
4
+ * File extensions treated as downloadable assets when no explicit `download`
5
+ * attribute is present on the link. Mirrors the defaults used by Plausible's
6
+ * `fileDownloads` extension, with a few common additions.
7
+ */
8
+ const DEFAULT_DOWNLOAD_EXTENSIONS = new Set([
9
+ '7z',
10
+ 'avi',
11
+ 'csv',
12
+ 'dmg',
13
+ 'doc',
14
+ 'docx',
15
+ 'exe',
16
+ 'gz',
17
+ 'key',
18
+ 'mkv',
19
+ 'mp3',
20
+ 'mp4',
21
+ 'mov',
22
+ 'msi',
23
+ 'numbers',
24
+ 'odp',
25
+ 'ods',
26
+ 'odt',
27
+ 'pages',
28
+ 'pdf',
29
+ 'pkg',
30
+ 'ppt',
31
+ 'pptx',
32
+ 'rar',
33
+ 'rtf',
34
+ 'tar',
35
+ 'tgz',
36
+ 'txt',
37
+ 'wav',
38
+ 'webm',
39
+ 'wma',
40
+ 'xls',
41
+ 'xlsx',
42
+ 'xz',
43
+ 'zip',
44
+ ]);
45
+ const LINK_TEXT_MAX = 80;
46
+ const CLICK_THROTTLE_MS = 300;
47
+ function sendOutbound(creds, eventName, data) {
48
+ sendBeaconCollect({
49
+ collectUrl: creds.collectUrl,
50
+ credentials: { appId: creds.appId, apiToken: creds.apiToken },
51
+ client: creds.client,
52
+ eventName,
53
+ eventType: 'navigation',
54
+ context: collectEventMeta(),
55
+ data,
56
+ });
57
+ }
58
+ function getLinkText(link) {
59
+ const raw = link.innerText ?? link.textContent ?? '';
60
+ const trimmed = raw.replace(/\s+/g, ' ').trim().slice(0, LINK_TEXT_MAX);
61
+ return trimmed || undefined;
62
+ }
63
+ function getFileExtension(pathname) {
64
+ const lastSlash = pathname.lastIndexOf('/');
65
+ const fileName = lastSlash >= 0 ? pathname.slice(lastSlash + 1) : pathname;
66
+ const dot = fileName.lastIndexOf('.');
67
+ if (dot <= 0 || dot === fileName.length - 1)
68
+ return undefined;
69
+ return fileName.slice(dot + 1).toLowerCase();
70
+ }
71
+ function categorizeLink(link, hrefAttr, currentHost) {
72
+ let url;
73
+ try {
74
+ url = new URL(hrefAttr, window.location.href);
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ const linkText = getLinkText(link);
80
+ if (url.protocol === 'mailto:') {
81
+ const address = decodeURIComponent(url.pathname || hrefAttr.replace(/^mailto:/i, ''));
82
+ return {
83
+ eventName: 'mailto_clicked',
84
+ data: {
85
+ url: hrefAttr,
86
+ ...(address ? { address } : {}),
87
+ ...(linkText ? { link_text: linkText } : {}),
88
+ },
89
+ };
90
+ }
91
+ if (url.protocol === 'tel:') {
92
+ const number = decodeURIComponent(url.pathname || hrefAttr.replace(/^tel:/i, ''));
93
+ return {
94
+ eventName: 'tel_clicked',
95
+ data: {
96
+ url: hrefAttr,
97
+ ...(number ? { number } : {}),
98
+ ...(linkText ? { link_text: linkText } : {}),
99
+ },
100
+ };
101
+ }
102
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
103
+ return null;
104
+ }
105
+ const isExternal = url.host !== currentHost;
106
+ const hasDownloadAttr = link.hasAttribute('download');
107
+ const ext = getFileExtension(url.pathname);
108
+ const isDownload = hasDownloadAttr || (ext !== undefined && DEFAULT_DOWNLOAD_EXTENSIONS.has(ext));
109
+ if (isDownload) {
110
+ const fileName = url.pathname.split('/').filter(Boolean).pop();
111
+ return {
112
+ eventName: 'file_downloaded',
113
+ data: {
114
+ url: url.href,
115
+ host: url.host,
116
+ ...(fileName ? { file_name: fileName } : {}),
117
+ ...(ext ? { file_extension: ext } : {}),
118
+ external: isExternal,
119
+ ...(linkText ? { link_text: linkText } : {}),
120
+ },
121
+ };
122
+ }
123
+ if (isExternal) {
124
+ return {
125
+ eventName: 'outbound_link_clicked',
126
+ data: {
127
+ url: url.href,
128
+ host: url.host,
129
+ ...(linkText ? { link_text: linkText } : {}),
130
+ ...(link.target ? { target: link.target } : {}),
131
+ },
132
+ };
133
+ }
134
+ return null;
135
+ }
136
+ /**
137
+ * Attaches a delegated listener that auto-tracks four navigation cases on
138
+ * `<a>` elements:
139
+ * - external `http(s)` links — `outbound_link_clicked`
140
+ * - file downloads (by `download` attr or known extension) — `file_downloaded`
141
+ * - `mailto:` links — `mailto_clicked`
142
+ * - `tel:` links — `tel_clicked`
143
+ *
144
+ * All events use `type: 'navigation'`. Listens to both `click` and `auxclick`
145
+ * (middle-click). Skips elements (or their ancestors) marked with
146
+ * `[data-nexly-ignore]`. Returns a cleanup function.
147
+ */
148
+ export function startOutboundLinkTracking(creds) {
149
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
150
+ return () => { };
151
+ }
152
+ const ac = new AbortController();
153
+ const { signal } = ac;
154
+ let lastDispatch = { href: '', ts: 0 };
155
+ function handle(e) {
156
+ if (e.defaultPrevented)
157
+ return;
158
+ if (e.type === 'click' && e.button !== 0 && e.button !== 1)
159
+ return;
160
+ if (e.type === 'auxclick' && e.button !== 1)
161
+ return;
162
+ const target = e.target;
163
+ if (!target)
164
+ return;
165
+ const link = target.closest('a');
166
+ if (!link)
167
+ return;
168
+ if (link.closest('[data-nexly-ignore]'))
169
+ return;
170
+ const hrefAttr = link.getAttribute('href');
171
+ if (!hrefAttr)
172
+ return;
173
+ const category = categorizeLink(link, hrefAttr, window.location.host);
174
+ if (!category)
175
+ return;
176
+ const now = Date.now();
177
+ if (lastDispatch.href === hrefAttr && now - lastDispatch.ts < CLICK_THROTTLE_MS) {
178
+ return;
179
+ }
180
+ lastDispatch = { href: hrefAttr, ts: now };
181
+ sendOutbound(creds, category.eventName, category.data);
182
+ }
183
+ document.addEventListener('click', handle, { signal, capture: true });
184
+ document.addEventListener('auxclick', handle, { signal, capture: true });
185
+ return () => {
186
+ ac.abort();
187
+ };
188
+ }
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export type { NexlyEventInput, NexlyEventType, NexlyInit } from '@nexly/core';
6
6
  export { collectBrowserMeta, collectSessionMeta, collectEventMeta } from './browser-meta.js';
7
7
  export { getSessionId, getVisitorId } from './session.js';
8
8
  export { sendBeaconCollect, type SendBeaconCollectInput } from './beacon.js';
9
- export { getDefaultPathname, sendPageViewBeacon, startEngagementTracking } from './events/index.js';
9
+ export { getDefaultPathname, sendPageViewBeacon, startEngagementTracking, startOutboundLinkTracking, } from './events/index.js';
10
10
  export type { CollectCredentials, IngestCredentials, TrackEventInput } from '@nexly/core';
11
11
  export { buildCollectPayload } from '@nexly/core';
12
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAClC,YAAY,EAAE,eAAe,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAC7E,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AAC5F,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AACzD,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,MAAM,aAAa,CAAA;AAC5E,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAA;AACnG,YAAY,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AACzF,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAClC,YAAY,EAAE,eAAe,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAC7E,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AAC5F,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AACzD,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,MAAM,aAAa,CAAA;AAC5E,OAAO,EACL,kBAAkB,EAClB,kBAAkB,EAClB,uBAAuB,EACvB,yBAAyB,GAC1B,MAAM,mBAAmB,CAAA;AAC1B,YAAY,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AACzF,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA"}
package/dist/index.js CHANGED
@@ -5,5 +5,5 @@ export { Nexly } from './nexly.js';
5
5
  export { collectBrowserMeta, collectSessionMeta, collectEventMeta } from './browser-meta.js';
6
6
  export { getSessionId, getVisitorId } from './session.js';
7
7
  export { sendBeaconCollect } from './beacon.js';
8
- export { getDefaultPathname, sendPageViewBeacon, startEngagementTracking } from './events/index.js';
8
+ export { getDefaultPathname, sendPageViewBeacon, startEngagementTracking, startOutboundLinkTracking, } from './events/index.js';
9
9
  export { buildCollectPayload } from '@nexly/core';
package/dist/nexly.d.ts CHANGED
@@ -5,6 +5,7 @@ import { NexlyBase, type NexlyInit } from '@nexly/core';
5
5
  */
6
6
  export declare class Nexly extends NexlyBase {
7
7
  private engagementStop;
8
+ private outboundLinksStop;
8
9
  /** Creates (or replaces) the browser singleton. Default `client` is `NexlyClient.Web`. */
9
10
  static init(options: NexlyInit): Nexly;
10
11
  /** Returns the browser singleton, or `null` if not yet initialized. */
@@ -20,5 +21,11 @@ export declare class Nexly extends NexlyBase {
20
21
  * Returns a cleanup function.
21
22
  */
22
23
  startEngagement(): () => void;
24
+ /**
25
+ * Attaches a delegated click listener that auto-tracks outbound links,
26
+ * file downloads, `mailto:` and `tel:` clicks. Idempotent: re-calling stops
27
+ * the previous subscription first. Returns a cleanup function.
28
+ */
29
+ startOutboundLinks(): () => void;
23
30
  }
24
31
  //# sourceMappingURL=nexly.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"nexly.d.ts","sourceRoot":"","sources":["../src/nexly.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAe,KAAK,SAAS,EAAE,MAAM,aAAa,CAAA;AAMpE;;;GAGG;AACH,qBAAa,KAAM,SAAQ,SAAS;IAClC,OAAO,CAAC,cAAc,CAA4B;IAElD,0FAA0F;IAC1F,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,SAAS,GAAG,KAAK;IAMtC,uEAAuE;IACvE,MAAM,CAAC,WAAW,IAAI,KAAK,GAAG,IAAI;gBAItB,IAAI,EAAE,SAAS;cAIR,SAAS,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO;cAKpD,mBAAmB,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;cAI9C,qBAAqB,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAI1D,cAAc,IAAI,MAAM;IAIjC;;;;OAIG;IACH,eAAe,IAAI,MAAM,IAAI;CAe9B"}
1
+ {"version":3,"file":"nexly.d.ts","sourceRoot":"","sources":["../src/nexly.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAe,KAAK,SAAS,EAAE,MAAM,aAAa,CAAA;AAOpE;;;GAGG;AACH,qBAAa,KAAM,SAAQ,SAAS;IAClC,OAAO,CAAC,cAAc,CAA4B;IAClD,OAAO,CAAC,iBAAiB,CAA4B;IAErD,0FAA0F;IAC1F,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,SAAS,GAAG,KAAK;IAMtC,uEAAuE;IACvE,MAAM,CAAC,WAAW,IAAI,KAAK,GAAG,IAAI;gBAItB,IAAI,EAAE,SAAS;cAIR,SAAS,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO;cAKpD,mBAAmB,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;cAI9C,qBAAqB,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAI1D,cAAc,IAAI,MAAM;IAIjC;;;;OAIG;IACH,eAAe,IAAI,MAAM,IAAI;IAgB7B;;;;OAIG;IACH,kBAAkB,IAAI,MAAM,IAAI;CAejC"}
package/dist/nexly.js CHANGED
@@ -2,6 +2,7 @@ import { NexlyBase, NexlyClient } from '@nexly/core';
2
2
  import { sendPayloadBeacon } from './beacon.js';
3
3
  import { collectEventMeta, collectSessionMeta } from './browser-meta.js';
4
4
  import { startEngagementTracking } from './events/engagement.js';
5
+ import { startOutboundLinkTracking } from './events/outbound-links.js';
5
6
  import { getDefaultPathname } from './events/pageview.js';
6
7
  /**
7
8
  * Browser-specific Nexly client. Implements transport via `navigator.sendBeacon`
@@ -9,6 +10,7 @@ import { getDefaultPathname } from './events/pageview.js';
9
10
  */
10
11
  export class Nexly extends NexlyBase {
11
12
  engagementStop = null;
13
+ outboundLinksStop = null;
12
14
  /** Creates (or replaces) the browser singleton. Default `client` is `NexlyClient.Web`. */
13
15
  static init(options) {
14
16
  const instance = new Nexly({ ...options, client: options.client ?? NexlyClient.Web });
@@ -58,4 +60,26 @@ export class Nexly extends NexlyBase {
58
60
  this.engagementStop = null;
59
61
  };
60
62
  }
63
+ /**
64
+ * Attaches a delegated click listener that auto-tracks outbound links,
65
+ * file downloads, `mailto:` and `tel:` clicks. Idempotent: re-calling stops
66
+ * the previous subscription first. Returns a cleanup function.
67
+ */
68
+ startOutboundLinks() {
69
+ if (this.disabled)
70
+ return () => { };
71
+ this.outboundLinksStop?.();
72
+ const stop = startOutboundLinkTracking({
73
+ collectUrl: this.collectUrl,
74
+ appId: this.appId,
75
+ apiToken: this.key,
76
+ client: this.client,
77
+ });
78
+ this.outboundLinksStop = stop;
79
+ return () => {
80
+ stop();
81
+ if (this.outboundLinksStop === stop)
82
+ this.outboundLinksStop = null;
83
+ };
84
+ }
61
85
  }
package/dist/session.d.ts CHANGED
@@ -4,6 +4,10 @@
4
4
  export declare function getVisitorId(): string;
5
5
  /**
6
6
  * Session ID with 30-minute inactivity timeout.
7
+ *
8
+ * Stored in `localStorage` (not `sessionStorage`) so the same session is shared
9
+ * across tabs and survives tab/window close. The 30-minute inactivity window is
10
+ * enforced via `SESSION_TS_KEY`, matching Plausible's visit definition.
7
11
  */
8
12
  export declare function getSessionId(): string;
9
13
  //# sourceMappingURL=session.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAoBA;;GAEG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAUrC;AAED;;GAEG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAkBrC"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAoBA;;GAEG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAUrC;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAkBrC"}
package/dist/session.js CHANGED
@@ -30,12 +30,16 @@ export function getVisitorId() {
30
30
  }
31
31
  /**
32
32
  * Session ID with 30-minute inactivity timeout.
33
+ *
34
+ * Stored in `localStorage` (not `sessionStorage`) so the same session is shared
35
+ * across tabs and survives tab/window close. The 30-minute inactivity window is
36
+ * enforced via `SESSION_TS_KEY`, matching Plausible's visit definition.
33
37
  */
34
38
  export function getSessionId() {
35
- if (typeof sessionStorage === 'undefined') {
39
+ if (typeof localStorage === 'undefined') {
36
40
  return generateId();
37
41
  }
38
- const existing = sessionStorage.getItem(SESSION_KEY);
42
+ const existing = localStorage.getItem(SESSION_KEY);
39
43
  const tsRaw = localStorage.getItem(SESSION_TS_KEY);
40
44
  const lastTs = tsRaw ? Number(tsRaw) : 0;
41
45
  const now = nowMs();
@@ -44,7 +48,7 @@ export function getSessionId() {
44
48
  return existing;
45
49
  }
46
50
  const id = generateId();
47
- sessionStorage.setItem(SESSION_KEY, id);
51
+ localStorage.setItem(SESSION_KEY, id);
48
52
  localStorage.setItem(SESSION_TS_KEY, String(now));
49
53
  return id;
50
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexly/web",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "Nexly browser ingest SDK: sendBeacon transport, DOM metadata, engagement listeners on top of @nexly/core",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -38,7 +38,7 @@
38
38
  "node": ">=20"
39
39
  },
40
40
  "dependencies": {
41
- "@nexly/core": "0.14.0"
41
+ "@nexly/core": "0.15.1"
42
42
  },
43
43
  "devDependencies": {
44
44
  "typescript": "~6.0.2"