@unifeather/tracker 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dennis Hendricks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # @unifeather/tracker
2
+
3
+ Tiny (~1 KB) privacy-friendly client-side tracker for
4
+ [unifeather](https://github.com/dennishendricks/unifeather). Minimal by default —
5
+ no cookies, no persistent ids — so it's usable without a consent banner. Cookie
6
+ id, session id and custom properties are explicitly opt-in.
7
+
8
+ ```sh
9
+ bun add @unifeather/tracker # or: npm i @unifeather/tracker
10
+ ```
11
+
12
+ ```ts
13
+ import { createTracker } from "@unifeather/tracker";
14
+
15
+ // Privacy-friendly default: auto pageview with active time, nothing else.
16
+ const tracker = createTracker({ endpoint: "/collect" });
17
+ tracker.track("signup"); // custom event
18
+ ```
19
+
20
+ Prefer no build step? Copy-paste the self-hosted `<script>` snippet
21
+ (`@unifeather/tracker/snippet`) instead — see the
22
+ [main README](https://github.com/dennishendricks/unifeather#readme).
23
+
24
+ ## Parameters
25
+
26
+ ### `createTracker(options)`
27
+
28
+ | Parameter | Description | Required |
29
+ | --- | --- | --- |
30
+ | `endpoint` | URL of the collect endpoint (e.g. `"https://analytics.example.com/collect"`). | **required** |
31
+ | `userId` | App-provided stable user id (e.g. a logged-in user). Takes precedence over `cookieId`. | optional |
32
+ | `cookieId` | Anonymous id in a long-lived first-party cookie. `true` for defaults, or a `CookieOptions` object. Ignored if `userId` is set. Opt-in, typically needs user consent. | optional |
33
+ | `session` | Session id in a short-lived, sliding-expiry cookie. `true` for defaults, or a `SessionOptions` object. Opt-in. | optional |
34
+ | `properties` | Custom properties object merged into every event. | optional |
35
+ | `maxActiveMs` | Cap on measured active (foreground) time, in milliseconds. Defaults to `600000` (10 min). | optional |
36
+ | `autoTrack` | Send a pageview automatically when the page is hidden/unloaded. Defaults to `true`. | optional |
37
+ | `includeScreen` | Include screen width/height in events. Defaults to `true`. | optional |
38
+
39
+ ### `CookieOptions` (passed to `cookieId`)
40
+
41
+ | Parameter | Description | Default |
42
+ | --- | --- | --- |
43
+ | `name` | Cookie name. | `"uf_uid"` |
44
+ | `maxAgeDays` | Cookie lifetime in days. | `365` |
45
+ | `sameSite` | `SameSite` policy — `"Lax"` \| `"Strict"` \| `"None"`. | `"Lax"` |
46
+
47
+ ### `SessionOptions` (passed to `session`)
48
+
49
+ | Parameter | Description | Default |
50
+ | --- | --- | --- |
51
+ | `name` | Cookie name. | `"uf_sid"` |
52
+ | `timeoutMinutes` | Inactivity timeout before the session cookie lapses (sliding). | `30` |
53
+ | `sameSite` | `SameSite` policy — `"Lax"` \| `"Strict"` \| `"None"`. | `"Lax"` |
54
+
55
+ ### Snippet `data-*` attributes
56
+
57
+ The `@unifeather/tracker/snippet` build reads these from the `<script>` tag. Cookie
58
+ and session are booleans only — for nested options use `createTracker`.
59
+
60
+ | Attribute | Description | Default / required |
61
+ | --- | --- | --- |
62
+ | `data-endpoint` | Collect endpoint URL. | **required** |
63
+ | `data-user-id` | App-provided stable user id. | optional |
64
+ | `data-cookie-id` | Enable the anonymous cookie id (`""` or `"true"` to enable). | optional |
65
+ | `data-session` | Enable the session id (`""` or `"true"` to enable). | optional |
66
+ | `data-props` | Custom properties as a JSON object, e.g. `'{"plan":"pro"}'`. Malformed JSON is ignored. | optional |
67
+
68
+ MIT © Dennis Hendricks
@@ -0,0 +1,96 @@
1
+ // src/cookie.ts
2
+ var uuid = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
3
+ var readCookie = (name) => {
4
+ const prefix = `${name}=`;
5
+ for (const part of document.cookie.split("; ")) {
6
+ if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length));
7
+ }
8
+ return void 0;
9
+ };
10
+ var writeCookie = (name, value, maxAgeSeconds, sameSite = "Lax") => {
11
+ const secure = location.protocol === "https:" ? "; Secure" : "";
12
+ document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=${sameSite}${secure}`;
13
+ };
14
+ var getOrCreateCookieId = (options = {}) => {
15
+ const name = options.name ?? "uf_uid";
16
+ const existing = readCookie(name);
17
+ if (existing) return existing;
18
+ const id = uuid();
19
+ writeCookie(name, id, (options.maxAgeDays ?? 365) * 86400, options.sameSite);
20
+ return id;
21
+ };
22
+
23
+ // src/session.ts
24
+ var getSessionId = (options = {}) => {
25
+ const name = options.name ?? "uf_sid";
26
+ const timeoutSeconds = (options.timeoutMinutes ?? 30) * 60;
27
+ const id = readCookie(name) ?? uuid();
28
+ writeCookie(name, id, timeoutSeconds, options.sameSite);
29
+ return id;
30
+ };
31
+
32
+ // src/index.ts
33
+ var uuid2 = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
34
+ var post = (endpoint, payload) => {
35
+ const body = JSON.stringify(payload);
36
+ if (navigator.sendBeacon?.(endpoint, new Blob([body], { type: "application/json" }))) return;
37
+ void fetch(endpoint, {
38
+ method: "POST",
39
+ body,
40
+ headers: { "Content-Type": "application/json" },
41
+ keepalive: true
42
+ }).catch(() => {
43
+ });
44
+ };
45
+ var createTracker = (options) => {
46
+ const { endpoint, maxActiveMs = 10 * 60 * 1e3, autoTrack = true, includeScreen = true } = options;
47
+ const userId = options.userId ?? (options.cookieId ? getOrCreateCookieId(typeof options.cookieId === "object" ? options.cookieId : {}) : void 0);
48
+ const sessionId = options.session ? getSessionId(typeof options.session === "object" ? options.session : {}) : void 0;
49
+ let activeMs = 0;
50
+ let resumedAt = document.hasFocus() ? performance.now() : null;
51
+ const accumulate = () => {
52
+ if (resumedAt !== null) activeMs = Math.min(activeMs + (performance.now() - resumedAt), maxActiveMs);
53
+ };
54
+ const resume = () => {
55
+ if (resumedAt === null && activeMs < maxActiveMs) resumedAt = performance.now();
56
+ };
57
+ const pause = () => {
58
+ accumulate();
59
+ resumedAt = null;
60
+ };
61
+ const track = (name = "pageview", properties) => {
62
+ accumulate();
63
+ const merged = options.properties || properties ? { ...options.properties, ...properties } : void 0;
64
+ post(endpoint, {
65
+ eventId: uuid2(),
66
+ name,
67
+ timestamp: Date.now(),
68
+ url: location.href,
69
+ referrer: document.referrer || void 0,
70
+ language: navigator.language,
71
+ activeSeconds: Math.round(activeMs / 1e3),
72
+ screenWidth: includeScreen ? screen.width : void 0,
73
+ screenHeight: includeScreen ? screen.height : void 0,
74
+ userId,
75
+ sessionId,
76
+ properties: merged
77
+ });
78
+ };
79
+ if (autoTrack) {
80
+ let sent = false;
81
+ const flush = () => {
82
+ if (sent) return;
83
+ sent = true;
84
+ pause();
85
+ track("pageview");
86
+ };
87
+ document.addEventListener("visibilitychange", () => {
88
+ if (document.visibilityState === "hidden") flush();
89
+ else resume();
90
+ });
91
+ addEventListener("pagehide", flush);
92
+ }
93
+ return { track, pageview: (properties) => track("pageview", properties) };
94
+ };
95
+
96
+ export { createTracker };
@@ -0,0 +1,21 @@
1
+ export type SameSite = "Lax" | "Strict" | "None";
2
+ /** Options for the opt-in cookie-based anonymous user id. */
3
+ export interface CookieOptions {
4
+ /** Cookie name. Defaults to "uf_uid". */
5
+ readonly name?: string;
6
+ /** Lifetime in days. Defaults to 365. */
7
+ readonly maxAgeDays?: number;
8
+ /** SameSite policy. Defaults to "Lax". */
9
+ readonly sameSite?: SameSite;
10
+ }
11
+ /** Generate a random id (RFC 4122 UUID when available). Shared internal helper. */
12
+ export declare const uuid: () => string;
13
+ /** Read a cookie value by name, or undefined. Shared internal helper. */
14
+ export declare const readCookie: (name: string) => string | undefined;
15
+ /** Write a cookie. Shared internal helper. `Secure` is added on HTTPS. */
16
+ export declare const writeCookie: (name: string, value: string, maxAgeSeconds: number, sameSite?: SameSite) => void;
17
+ /**
18
+ * Read the anonymous user id from a long-lived first-party cookie, creating and
19
+ * persisting a new random id on first visit. Opt-in — typically requires consent.
20
+ */
21
+ export declare const getOrCreateCookieId: (options?: CookieOptions) => string;
@@ -0,0 +1,41 @@
1
+ import type { Properties } from "@unifeather/core";
2
+ import { type CookieOptions } from "./cookie.js";
3
+ import { type SessionOptions } from "./session.js";
4
+ export type { CookieOptions } from "./cookie.js";
5
+ export type { SessionOptions } from "./session.js";
6
+ export interface TrackerOptions {
7
+ /** URL of the collect endpoint (e.g. "https://analytics.example.com/collect"). */
8
+ readonly endpoint: string;
9
+ /**
10
+ * Custom, app-provided user id. Takes precedence over `cookieId`. Use this
11
+ * when you already have a stable id (e.g. a logged-in user).
12
+ */
13
+ readonly userId?: string;
14
+ /**
15
+ * Anonymous user id persisted in a long-lived first-party cookie. `true` for
16
+ * defaults or pass options. Opt-in — typically requires consent. Ignored if
17
+ * `userId` is set.
18
+ */
19
+ readonly cookieId?: boolean | CookieOptions;
20
+ /** Session id in a short-lived, sliding-expiry cookie. Opt-in. */
21
+ readonly session?: boolean | SessionOptions;
22
+ /** Custom properties merged into every event. */
23
+ readonly properties?: Properties;
24
+ /** Cap on measured active time, in ms. Defaults to 10 minutes. */
25
+ readonly maxActiveMs?: number;
26
+ /** Send a pageview automatically when the page is hidden. Defaults to true. */
27
+ readonly autoTrack?: boolean;
28
+ /** Include screen width/height. Defaults to true. */
29
+ readonly includeScreen?: boolean;
30
+ }
31
+ export interface Tracker {
32
+ /** Send a custom event. Defaults to a "pageview". */
33
+ track: (name?: string, properties?: Properties) => void;
34
+ /** Convenience alias for a pageview (e.g. on SPA route changes). */
35
+ pageview: (properties?: Properties) => void;
36
+ }
37
+ /**
38
+ * Create a tracker. Minimal by default (no cookies, no ids). Enable
39
+ * `cookieId`, `session`, `userId` and `properties` as needed.
40
+ */
41
+ export declare const createTracker: (options: TrackerOptions) => Tracker;
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createTracker } from './chunk-TH4L6Z4L.js';
@@ -0,0 +1,16 @@
1
+ import { type SameSite } from "./cookie.js";
2
+ /** Options for the opt-in cookie-based session id. */
3
+ export interface SessionOptions {
4
+ /** Cookie name. Defaults to "uf_sid". */
5
+ readonly name?: string;
6
+ /** Inactivity timeout in minutes before the session cookie expires. Defaults to 30. */
7
+ readonly timeoutMinutes?: number;
8
+ /** SameSite policy. Defaults to "Lax". */
9
+ readonly sameSite?: SameSite;
10
+ }
11
+ /**
12
+ * Return the current session id from a short-lived first-party cookie. The
13
+ * cookie is written with a sliding expiry equal to the inactivity timeout, so
14
+ * it renews on each event and lapses after the visitor is idle. Opt-in.
15
+ */
16
+ export declare const getSessionId: (options?: SessionOptions) => string;
@@ -0,0 +1,11 @@
1
+ import { type Tracker } from "./index.js";
2
+ /**
3
+ * Initialize a tracker from a `<script>` tag's `data-*` attributes. Reads the
4
+ * currently executing script (or the last matching one). Returns the tracker,
5
+ * or undefined if no endpoint is configured.
6
+ *
7
+ * Supported attributes:
8
+ * data-endpoint (required) data-user-id data-cookie-id
9
+ * data-session data-props (JSON object)
10
+ */
11
+ export declare const initFromScript: (script?: HTMLScriptElement) => Tracker | undefined;
@@ -0,0 +1 @@
1
+ (function(){'use strict';var p=()=>crypto.randomUUID?.()??`${Date.now()}-${Math.random().toString(36).slice(2)}`,u=e=>{let t=`${e}=`;for(let o of document.cookie.split("; "))if(o.startsWith(t))return decodeURIComponent(o.slice(t.length))},m=(e,t,o,r="Lax")=>{let a=location.protocol==="https:"?"; Secure":"";document.cookie=`${e}=${encodeURIComponent(t)}; Max-Age=${o}; Path=/; SameSite=${r}${a}`;},f=(e={})=>{let t=e.name??"uf_uid",o=u(t);if(o)return o;let r=p();return m(t,r,(e.maxAgeDays??365)*86400,e.sameSite),r};var g=(e={})=>{let t=e.name??"uf_sid",o=(e.timeoutMinutes??30)*60,r=u(t)??p();return m(t,r,o,e.sameSite),r};var O=()=>crypto.randomUUID?.()??`${Date.now()}-${Math.random().toString(36).slice(2)}`,b=(e,t)=>{let o=JSON.stringify(t);navigator.sendBeacon?.(e,new Blob([o],{type:"application/json"}))||fetch(e,{method:"POST",body:o,headers:{"Content-Type":"application/json"},keepalive:true}).catch(()=>{});},S=e=>{let{endpoint:t,maxActiveMs:o=600*1e3,autoTrack:r=true,includeScreen:a=true}=e,v=e.userId??(e.cookieId?f(typeof e.cookieId=="object"?e.cookieId:{}):void 0),h=e.session?g(typeof e.session=="object"?e.session:{}):void 0,c=0,i=document.hasFocus()?performance.now():null,l=()=>{i!==null&&(c=Math.min(c+(performance.now()-i),o));},x=()=>{i===null&&c<o&&(i=performance.now());},I=()=>{l(),i=null;},d=(n="pageview",s)=>{l();let w=e.properties||s?{...e.properties,...s}:void 0;b(t,{eventId:O(),name:n,timestamp:Date.now(),url:location.href,referrer:document.referrer||void 0,language:navigator.language,activeSeconds:Math.round(c/1e3),screenWidth:a?screen.width:void 0,screenHeight:a?screen.height:void 0,userId:v,sessionId:h,properties:w});};if(r){let n=false,s=()=>{n||(n=true,I(),d("pageview"));};document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?s():x();}),addEventListener("pagehide",s);}return {track:d,pageview:n=>d("pageview",n)}};var y=e=>e===""||e==="true",k=e=>{let t=document.currentScript??document.querySelector("script[data-endpoint]");if(!t)return;let o=t.dataset.endpoint;if(!o)return;let r;if(t.dataset.props)try{r=JSON.parse(t.dataset.props);}catch{}return S({endpoint:o,userId:t.dataset.userId,cookieId:y(t.dataset.cookieId),session:y(t.dataset.session),properties:r})};window.unifeather=k();})();
@@ -0,0 +1,6 @@
1
+ import { initFromScript } from "./snippet.js";
2
+ declare global {
3
+ interface Window {
4
+ unifeather?: ReturnType<typeof initFromScript>;
5
+ }
6
+ }
@@ -0,0 +1,26 @@
1
+ import { createTracker } from './chunk-TH4L6Z4L.js';
2
+
3
+ // src/snippet.ts
4
+ var bool = (value) => value === "" || value === "true";
5
+ var initFromScript = (script) => {
6
+ const el = script ?? document.currentScript ?? document.querySelector("script[data-endpoint]");
7
+ if (!el) return void 0;
8
+ const endpoint = el.dataset.endpoint;
9
+ if (!endpoint) return void 0;
10
+ let properties;
11
+ if (el.dataset.props) {
12
+ try {
13
+ properties = JSON.parse(el.dataset.props);
14
+ } catch {
15
+ }
16
+ }
17
+ return createTracker({
18
+ endpoint,
19
+ userId: el.dataset.userId,
20
+ cookieId: bool(el.dataset.cookieId),
21
+ session: bool(el.dataset.session),
22
+ properties
23
+ });
24
+ };
25
+
26
+ export { initFromScript };
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@unifeather/tracker",
3
+ "version": "0.1.0",
4
+ "description": "Tiny (~1KB) privacy-friendly client-side tracker. Minimal by default; cookie id, session id and custom properties are opt-in.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Dennis Hendricks <dennis@hendricks.rocks>",
8
+ "keywords": [
9
+ "analytics",
10
+ "privacy",
11
+ "web-analytics",
12
+ "tracking",
13
+ "tracker",
14
+ "browser",
15
+ "lightweight"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/dennishendricks/unifeather.git",
20
+ "directory": "packages/tracker"
21
+ },
22
+ "homepage": "https://github.com/dennishendricks/unifeather#readme",
23
+ "bugs": "https://github.com/dennishendricks/unifeather/issues",
24
+ "sideEffects": false,
25
+ "main": "./dist/index.js",
26
+ "module": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ },
33
+ "./snippet": {
34
+ "types": "./dist/snippet.d.ts",
35
+ "import": "./dist/snippet.js",
36
+ "default": "./dist/snippet.global.js"
37
+ }
38
+ },
39
+ "files": [
40
+ "dist"
41
+ ],
42
+ "scripts": {
43
+ "prepublishOnly": "bun run build",
44
+ "build": "tsup && tsc --emitDeclarationOnly",
45
+ "dev": "tsup --watch",
46
+ "typecheck": "tsc --noEmit"
47
+ },
48
+ "dependencies": {
49
+ "@unifeather/core": "^0.1.0"
50
+ },
51
+ "devDependencies": {
52
+ "tsup": "^8.5.1",
53
+ "typescript": "^7.0.2"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }