@unifeather/core 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 +21 -0
- package/README.md +23 -0
- package/dist/adapter.d.ts +54 -0
- package/dist/event.d.ts +67 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +78 -0
- package/dist/normalize.d.ts +18 -0
- package/dist/ua-parser.d.ts +8 -0
- package/package.json +48 -0
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,23 @@
|
|
|
1
|
+
# @unifeather/core
|
|
2
|
+
|
|
3
|
+
Shared types, event normalization, UA parsing and the storage `Adapter`
|
|
4
|
+
contract for [unifeather](https://github.com/dennishendricks/unifeather) — a
|
|
5
|
+
lightweight, privacy-friendly, tree-shakeable website-analytics toolkit.
|
|
6
|
+
|
|
7
|
+
This package is the common foundation the other `@unifeather/*` packages build
|
|
8
|
+
on. You usually depend on it transitively; install it directly only when
|
|
9
|
+
implementing a custom storage adapter or reusing the event schema.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
bun add @unifeather/core # or: npm i @unifeather/core
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { normalizeEvent, type Adapter, type TrackingEvent } from "@unifeather/core";
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
See the [main README](https://github.com/dennishendricks/unifeather#readme) for the
|
|
20
|
+
full picture and [AGENTS.md](https://github.com/dennishendricks/unifeather/blob/main/AGENTS.md)
|
|
21
|
+
for the adapter contract.
|
|
22
|
+
|
|
23
|
+
MIT © Dennis Hendricks
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { TrackingEvent } from "./event.js";
|
|
2
|
+
/** Time-bucket granularity for the stats timeseries. */
|
|
3
|
+
export type Interval = "hour" | "day";
|
|
4
|
+
/** Parameters for an aggregated stats query. */
|
|
5
|
+
export interface StatsQuery {
|
|
6
|
+
/** Inclusive lower bound, milliseconds since epoch. */
|
|
7
|
+
readonly from: number;
|
|
8
|
+
/** Exclusive upper bound, milliseconds since epoch. */
|
|
9
|
+
readonly to: number;
|
|
10
|
+
/** Max rows for top-N lists (pages, referrers). Defaults to 10. */
|
|
11
|
+
readonly limit?: number;
|
|
12
|
+
/** Timeseries bucket size. Defaults to "day". */
|
|
13
|
+
readonly interval?: Interval;
|
|
14
|
+
}
|
|
15
|
+
export interface CountedPath {
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly views: number;
|
|
18
|
+
}
|
|
19
|
+
export interface CountedReferrer {
|
|
20
|
+
readonly referrer: string;
|
|
21
|
+
readonly views: number;
|
|
22
|
+
}
|
|
23
|
+
export interface TimeseriesPoint {
|
|
24
|
+
/** ISO date/hour label, e.g. "2026-07-13" or "2026-07-13T14:00". */
|
|
25
|
+
readonly date: string;
|
|
26
|
+
readonly views: number;
|
|
27
|
+
}
|
|
28
|
+
/** Aggregated result consumed by the dashboard. */
|
|
29
|
+
export interface StatsResult {
|
|
30
|
+
readonly totals: {
|
|
31
|
+
readonly views: number;
|
|
32
|
+
/** Distinct visitors, when the backend can compute it. */
|
|
33
|
+
readonly visitors?: number;
|
|
34
|
+
readonly avgActiveSeconds?: number;
|
|
35
|
+
};
|
|
36
|
+
readonly topPages: readonly CountedPath[];
|
|
37
|
+
readonly topReferrers: readonly CountedReferrer[];
|
|
38
|
+
readonly timeseries: readonly TimeseriesPoint[];
|
|
39
|
+
/** True when the result was extrapolated from a sample rather than a full scan. */
|
|
40
|
+
readonly sampled: boolean;
|
|
41
|
+
/** Fraction of rows actually scanned (e.g. 0.1 = 10%), when sampled. */
|
|
42
|
+
readonly sampleRate?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The storage contract. Every backend (Analytics Engine, SQL, and future
|
|
46
|
+
* ClickHouse/DuckDB adapters) implements this. `insert` handles ingestion;
|
|
47
|
+
* `stats` handles aggregation for the dashboard.
|
|
48
|
+
*/
|
|
49
|
+
export interface Adapter {
|
|
50
|
+
insert(event: TrackingEvent): Promise<void>;
|
|
51
|
+
/** Optional bulk path; the server falls back to per-event insert if absent. */
|
|
52
|
+
insertBatch?(events: readonly TrackingEvent[]): Promise<void>;
|
|
53
|
+
stats(query: StatsQuery): Promise<StatsResult>;
|
|
54
|
+
}
|
package/dist/event.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value types allowed in custom properties. Kept deliberately narrow so every
|
|
3
|
+
* storage backend (columnar, relational, Analytics Engine) can represent them.
|
|
4
|
+
*/
|
|
5
|
+
export type PropValue = string | number | boolean;
|
|
6
|
+
/** Custom, caller-defined key/value pairs attached to an event. */
|
|
7
|
+
export type Properties = Record<string, PropValue>;
|
|
8
|
+
/**
|
|
9
|
+
* The raw payload as it leaves the browser. Intentionally minimal — everything
|
|
10
|
+
* that can be derived server-side (UA parsing, geo, visitor hashing) is NOT
|
|
11
|
+
* sent by the client, keeping the payload small and privacy-friendly.
|
|
12
|
+
*
|
|
13
|
+
* Only `eventId`, `timestamp` and `url` are guaranteed; the rest are optional
|
|
14
|
+
* and only present when the corresponding tracker option is enabled.
|
|
15
|
+
*/
|
|
16
|
+
export interface RawEvent {
|
|
17
|
+
/** Client-generated idempotency id. */
|
|
18
|
+
readonly eventId: string;
|
|
19
|
+
/** Event name, e.g. "pageview" (default) or a custom event. */
|
|
20
|
+
readonly name?: string;
|
|
21
|
+
/** Client clock, milliseconds since epoch. */
|
|
22
|
+
readonly timestamp: number;
|
|
23
|
+
/** Full location href at capture time. */
|
|
24
|
+
readonly url: string;
|
|
25
|
+
readonly referrer?: string;
|
|
26
|
+
readonly language?: string;
|
|
27
|
+
/** Foreground/active time on the page, in seconds. */
|
|
28
|
+
readonly activeSeconds?: number;
|
|
29
|
+
readonly screenWidth?: number;
|
|
30
|
+
readonly screenHeight?: number;
|
|
31
|
+
/** Custom id (passed by the app) or cookie-generated id — opt-in only. */
|
|
32
|
+
readonly userId?: string;
|
|
33
|
+
/** Session id — opt-in only. */
|
|
34
|
+
readonly sessionId?: string;
|
|
35
|
+
readonly properties?: Properties;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A fully normalized, storage-ready event. Produced by {@link normalizeEvent}
|
|
39
|
+
* after server-side enrichment. This is the single shape every {@link Adapter}
|
|
40
|
+
* persists and the shape stats queries aggregate over.
|
|
41
|
+
*/
|
|
42
|
+
export interface TrackingEvent {
|
|
43
|
+
readonly eventId: string;
|
|
44
|
+
readonly name: string;
|
|
45
|
+
/** Server-authoritative timestamp, milliseconds since epoch. */
|
|
46
|
+
readonly timestamp: number;
|
|
47
|
+
readonly hostname: string;
|
|
48
|
+
/** URL pathname (query string stripped unless configured otherwise). */
|
|
49
|
+
readonly path: string;
|
|
50
|
+
/** Bare referrer hostname, or undefined for direct/same-site traffic. */
|
|
51
|
+
readonly referrerHost?: string;
|
|
52
|
+
readonly language?: string;
|
|
53
|
+
readonly activeSeconds: number;
|
|
54
|
+
readonly browserName?: string;
|
|
55
|
+
readonly browserVersion?: string;
|
|
56
|
+
readonly osName?: string;
|
|
57
|
+
readonly osVersion?: string;
|
|
58
|
+
readonly screenWidth?: number;
|
|
59
|
+
readonly screenHeight?: number;
|
|
60
|
+
/** Two-letter country code, derived server-side (e.g. from CF-IPCountry). */
|
|
61
|
+
readonly country?: string;
|
|
62
|
+
/** Custom/app-provided or cookie-generated anonymous id — opt-in only. */
|
|
63
|
+
readonly userId?: string;
|
|
64
|
+
/** Session id from a session cookie — opt-in only. */
|
|
65
|
+
readonly sessionId?: string;
|
|
66
|
+
readonly properties?: Properties;
|
|
67
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { PropValue, Properties, RawEvent, TrackingEvent } from "./event.js";
|
|
2
|
+
export type { DeviceInfo } from "./ua-parser.js";
|
|
3
|
+
export { parseUA } from "./ua-parser.js";
|
|
4
|
+
export type { EnrichContext } from "./normalize.js";
|
|
5
|
+
export { normalizeEvent } from "./normalize.js";
|
|
6
|
+
export type { Adapter, Interval, StatsQuery, StatsResult, CountedPath, CountedReferrer, TimeseriesPoint } from "./adapter.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/ua-parser.ts
|
|
2
|
+
var underscoresToDots = (version) => version.replace(/_/g, ".");
|
|
3
|
+
var WINDOWS_VERSIONS = {
|
|
4
|
+
"10.0": "10/11",
|
|
5
|
+
"6.3": "8.1",
|
|
6
|
+
"6.2": "8",
|
|
7
|
+
"6.1": "7"
|
|
8
|
+
};
|
|
9
|
+
var BROWSER_RULES = [
|
|
10
|
+
{ name: "Edge", pattern: /Edg(?:e|A|iOS)?\/(\d+)/ },
|
|
11
|
+
{ name: "Opera", pattern: /OPR\/(\d+)/ },
|
|
12
|
+
{ name: "Samsung Internet", pattern: /SamsungBrowser\/(\d+)/ },
|
|
13
|
+
{ name: "Firefox", pattern: /(?:Firefox|FxiOS)\/(\d+)/ },
|
|
14
|
+
{ name: "Chrome", pattern: /(?:Chrome|CriOS)\/(\d+)/ },
|
|
15
|
+
{ name: "Safari", pattern: /Version\/(\d+).*Safari/ }
|
|
16
|
+
];
|
|
17
|
+
var OS_RULES = [
|
|
18
|
+
{ name: "iOS", pattern: /(?:iPhone|iPad); CPU (?:iPhone )?OS (\d+[_.]\d+)/, format: underscoresToDots },
|
|
19
|
+
{ name: "Android", pattern: /Android (\d+(?:\.\d+)?)/ },
|
|
20
|
+
{ name: "Windows", pattern: /Windows NT (\d+\.\d+)/, format: (v) => WINDOWS_VERSIONS[v] ?? v },
|
|
21
|
+
{ name: "macOS", pattern: /Mac OS X (\d+[_.]\d+(?:[_.]\d+)?)/, format: underscoresToDots },
|
|
22
|
+
{ name: "Linux", pattern: /Linux/ }
|
|
23
|
+
];
|
|
24
|
+
var matchFirst = (ua, rules) => {
|
|
25
|
+
for (const { name, pattern, format } of rules) {
|
|
26
|
+
const match = ua.match(pattern);
|
|
27
|
+
if (!match) continue;
|
|
28
|
+
const raw = match[1];
|
|
29
|
+
return { name, version: raw ? format?.(raw) ?? raw : void 0 };
|
|
30
|
+
}
|
|
31
|
+
return {};
|
|
32
|
+
};
|
|
33
|
+
var parseUA = (ua) => {
|
|
34
|
+
const browser = matchFirst(ua, BROWSER_RULES);
|
|
35
|
+
const os = matchFirst(ua, OS_RULES);
|
|
36
|
+
return {
|
|
37
|
+
browserName: browser.name,
|
|
38
|
+
browserVersion: browser.version,
|
|
39
|
+
osName: os.name,
|
|
40
|
+
osVersion: os.version
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// src/normalize.ts
|
|
45
|
+
var hostOf = (value) => {
|
|
46
|
+
if (!value) return void 0;
|
|
47
|
+
try {
|
|
48
|
+
return new URL(value).hostname || void 0;
|
|
49
|
+
} catch {
|
|
50
|
+
return void 0;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var normalizeEvent = (raw, context = {}) => {
|
|
54
|
+
const url = new URL(raw.url);
|
|
55
|
+
const device = context.userAgent ? parseUA(context.userAgent) : {};
|
|
56
|
+
const stripQuery = context.stripQuery ?? true;
|
|
57
|
+
const referrerHost = hostOf(raw.referrer);
|
|
58
|
+
return {
|
|
59
|
+
eventId: raw.eventId,
|
|
60
|
+
name: raw.name ?? "pageview",
|
|
61
|
+
timestamp: context.now ?? raw.timestamp,
|
|
62
|
+
hostname: url.hostname,
|
|
63
|
+
path: stripQuery ? url.pathname : url.pathname + url.search,
|
|
64
|
+
// Same-site referrers are noise; only keep genuine external referrers.
|
|
65
|
+
referrerHost: referrerHost && referrerHost !== url.hostname ? referrerHost : void 0,
|
|
66
|
+
language: raw.language,
|
|
67
|
+
activeSeconds: raw.activeSeconds ?? 0,
|
|
68
|
+
...device,
|
|
69
|
+
screenWidth: raw.screenWidth,
|
|
70
|
+
screenHeight: raw.screenHeight,
|
|
71
|
+
country: context.country,
|
|
72
|
+
userId: raw.userId,
|
|
73
|
+
sessionId: raw.sessionId,
|
|
74
|
+
properties: raw.properties
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export { normalizeEvent, parseUA };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { RawEvent, TrackingEvent } from "./event.js";
|
|
2
|
+
/** Server-side context merged into a raw event during normalization. */
|
|
3
|
+
export interface EnrichContext {
|
|
4
|
+
/** Server-authoritative timestamp (ms). Defaults to the client timestamp. */
|
|
5
|
+
readonly now?: number;
|
|
6
|
+
/** Raw User-Agent header, parsed into browser/OS. */
|
|
7
|
+
readonly userAgent?: string;
|
|
8
|
+
/** Two-letter country code (e.g. from the CF-IPCountry header). */
|
|
9
|
+
readonly country?: string;
|
|
10
|
+
/** Strip the query string from the stored path. Defaults to true. */
|
|
11
|
+
readonly stripQuery?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Turn a browser {@link RawEvent} into a storage-ready {@link TrackingEvent}.
|
|
15
|
+
* Pure and synchronous — geo/visitor-hash derivation happens in the caller and
|
|
16
|
+
* is passed in via {@link EnrichContext}.
|
|
17
|
+
*/
|
|
18
|
+
export declare const normalizeEvent: (raw: RawEvent, context?: EnrichContext) => TrackingEvent;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface DeviceInfo {
|
|
2
|
+
readonly browserName?: string;
|
|
3
|
+
readonly browserVersion?: string;
|
|
4
|
+
readonly osName?: string;
|
|
5
|
+
readonly osVersion?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Parse a User-Agent string into coarse browser/OS info. Zero dependencies. */
|
|
8
|
+
export declare const parseUA: (ua: string) => DeviceInfo;
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unifeather/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared types, event normalization, UA parsing and the storage Adapter contract for unifeather.",
|
|
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
|
+
"cookieless"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/dennishendricks/unifeather.git",
|
|
18
|
+
"directory": "packages/core"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/dennishendricks/unifeather#readme",
|
|
21
|
+
"bugs": "https://github.com/dennishendricks/unifeather/issues",
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"module": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"prepublishOnly": "bun run build",
|
|
37
|
+
"build": "tsup && tsc --emitDeclarationOnly",
|
|
38
|
+
"dev": "tsup --watch",
|
|
39
|
+
"typecheck": "tsc --noEmit"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"tsup": "^8.5.1",
|
|
43
|
+
"typescript": "^7.0.2"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|