@funelr/events 0.4.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 json
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,163 @@
1
+ # @funelr/events
2
+
3
+ Official JavaScript/TypeScript SDK for [funelr.io](https://funelr.io) — event tracking with batching, consent management, and automatic session handling.
4
+
5
+ ## Introduction
6
+
7
+ `@funelr/events` sends analytics events from browser applications to the funelr.io ingest API. Key characteristics:
8
+
9
+ - **Consent-first** — no data is collected until `setConsent(true)` is called
10
+ - **Batched delivery** — events are queued in memory and flushed by threshold, timer, or page lifecycle hooks
11
+ - **Resilient transport** — `sendBeacon` is preferred on page unload; `fetch` with exponential-backoff retry handles the rest
12
+ - **Zero config for sessions** — visitor and session identifiers are generated automatically via `crypto.randomUUID()` once consent is granted
13
+
14
+ ## Requirements
15
+
16
+ - Node.js ≥ 22 (build / server-side usage)
17
+ - A modern browser with `crypto.randomUUID`, `sessionStorage`, and `fetch`
18
+
19
+ ## Setup
20
+
21
+ ```bash
22
+ npm install @funelr/events
23
+ ```
24
+
25
+ ### Basic initialisation
26
+
27
+ ```ts
28
+ import { createFunnelClient } from "@funelr/events"
29
+
30
+ const client = createFunnelClient({
31
+ apiKey: "fjs_live_abc123",
32
+ endpoint: "https://ingest.funelr.io/v1/collect",
33
+ consent: true, // or call client.setConsent(true) after cookie banner
34
+ })
35
+ ```
36
+
37
+ ### With deferred consent
38
+
39
+ ```ts
40
+ const client = createFunnelClient({
41
+ apiKey: "fjs_live_abc123",
42
+ endpoint: "https://ingest.funelr.io/v1/collect",
43
+ // consent defaults to false — nothing is tracked yet
44
+ })
45
+
46
+ // later, once the user accepts the cookie banner:
47
+ client.setConsent(true)
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ ### Tracking events
53
+
54
+ ```ts
55
+ client.track("page_view")
56
+ client.track("cta_click", { label: "Get started", position: "hero" })
57
+ ```
58
+
59
+ `track()` is a no-op when:
60
+ - consent has not been granted
61
+ - `allowedEventNames` is configured and `eventName` is not in the list
62
+
63
+ ### Consent management
64
+
65
+ ```ts
66
+ // grant
67
+ client.setConsent(true)
68
+
69
+ // revoke — flushes nothing, clears queue and stored IDs
70
+ client.setConsent(false)
71
+ ```
72
+
73
+ ### Reading identifiers
74
+
75
+ ```ts
76
+ const sessionId = client.getSessionId() // scoped to the current tab (sessionStorage)
77
+ const anonymousId = client.getAnonymousId() // persistent across sessions (localStorage)
78
+ ```
79
+
80
+ Both return `undefined` when consent has not been granted or the storage API is unavailable (e.g. SSR).
81
+
82
+ ### Manual flush and teardown
83
+
84
+ ```ts
85
+ // send all queued events immediately
86
+ client.flush()
87
+
88
+ // flush, stop the timer, and remove page-lifecycle listeners
89
+ client.destroy()
90
+ ```
91
+
92
+ Call `destroy()` when unmounting a SPA layout or in framework cleanup hooks.
93
+
94
+ ### Configuration reference
95
+
96
+ | Option | Type | Default | Description |
97
+ |---|---|---|---|
98
+ | `endpoint` | `string` | — | Base URL of the ingest API (**required**) |
99
+ | `apiKey` | `string` | — | API key sent as `X-Api-Key` header |
100
+ | `consent` | `boolean` | `false` | Initial consent state |
101
+ | `allowedEventNames` | `readonly string[]` | — | Allowlist — unknown names are silently dropped |
102
+ | `batchSize` | `number` | `20` | Queue length that triggers an automatic flush |
103
+ | `flushInterval` | `number` | `5000` | Periodic flush interval in milliseconds |
104
+ | `maxRetries` | `number` | `3` | Retry attempts on network errors or 5xx responses |
105
+ | `sessionStorageKey` | `string` | `"funnel_session_id"` | Custom key for `sessionStorage` |
106
+ | `anonymousIdStorageKey` | `string` | `"funnel_anonymous_id"` | Custom key for `localStorage` |
107
+
108
+ ## Server-side payload validation
109
+
110
+ The Zod schema is exported for use in API routes or test assertions:
111
+
112
+ ```ts
113
+ import { eventPayloadSchema } from "@funelr/events"
114
+
115
+ const result = eventPayloadSchema.safeParse(req.body)
116
+ if (!result.success) {
117
+ return res.status(400).json(result.error)
118
+ }
119
+ ```
120
+
121
+ ## Standalone utilities
122
+
123
+ Session and validation helpers can be used independently of `createFunnelClient`:
124
+
125
+ ```ts
126
+ import {
127
+ getOrCreateSessionId,
128
+ getOrCreateAnonymousId,
129
+ clearSessionId,
130
+ clearAnonymousId,
131
+ isValidUUID,
132
+ isAllowedEventName,
133
+ } from "@funelr/events"
134
+ ```
135
+
136
+ ## Source structure
137
+
138
+ ```
139
+ src/
140
+ ├── client.ts # createFunnelClient — public API, batching, consent gate
141
+ ├── transport.ts # sendBatch — fetch + sendBeacon + retry logic
142
+ ├── session.ts # getOrCreateSessionId / getOrCreateAnonymousId
143
+ ├── validation.ts # isValidUUID, isAllowedEventName
144
+ ├── schemas.ts # Zod schema (eventPayloadSchema) and EventPayload type
145
+ ├── types.ts # TypeScript types for Stats & Analytics API responses
146
+ ├── constants.ts # SDK_VERSION, field length limits, UUID_V4_REGEX
147
+ └── index.ts # public re-exports
148
+ ```
149
+
150
+ ## Development
151
+
152
+ ```bash
153
+ npm run build # compile to dist/
154
+ npm run typecheck # tsc --noEmit
155
+ npm run test # vitest run
156
+ npm run test:watch # vitest (watch mode)
157
+ npm run lint # biome check
158
+ npm run lint:fix # biome check --write
159
+ ```
160
+
161
+ ## License
162
+
163
+ MIT
@@ -0,0 +1,116 @@
1
+ /** Configuration options for {@link createFunnelClient}. */
2
+ export interface FunnelClientConfig {
3
+ /**
4
+ * Base URL of the ingest endpoint.
5
+ *
6
+ * @example "https://ingest.funelr.io/v1/collect"
7
+ */
8
+ endpoint: string;
9
+ /**
10
+ * API key for the project. Events are sent as a batch to `{endpoint}/batch`
11
+ * with an `X-Api-Key` header.
12
+ */
13
+ apiKey?: string;
14
+ /**
15
+ * Optional allowlist of accepted event names. Events whose name is not
16
+ * present in this list are silently dropped. When omitted, all names are
17
+ * accepted.
18
+ */
19
+ allowedEventNames?: readonly string[];
20
+ /**
21
+ * Initial consent state. No data is collected until consent is `true`.
22
+ *
23
+ * @defaultValue `false`
24
+ */
25
+ consent?: boolean;
26
+ /**
27
+ * Custom `sessionStorage` key for the session identifier.
28
+ *
29
+ * @defaultValue `"funnel_session_id"`
30
+ */
31
+ sessionStorageKey?: string;
32
+ /**
33
+ * Custom `localStorage` key for the anonymous visitor identifier.
34
+ *
35
+ * @defaultValue `"funnel_anonymous_id"`
36
+ */
37
+ anonymousIdStorageKey?: string;
38
+ /**
39
+ * Number of queued events that triggers an automatic flush.
40
+ *
41
+ * @defaultValue `20`
42
+ */
43
+ batchSize?: number;
44
+ /**
45
+ * Interval in milliseconds between automatic flushes while events are queued.
46
+ *
47
+ * @defaultValue `5000`
48
+ */
49
+ flushInterval?: number;
50
+ /**
51
+ * Maximum retry attempts on network errors or 5xx responses.
52
+ *
53
+ * @defaultValue `3`
54
+ */
55
+ maxRetries?: number;
56
+ }
57
+ /** Public interface returned by {@link createFunnelClient}. */
58
+ export interface FunnelClient {
59
+ /**
60
+ * Records an event in the internal queue.
61
+ *
62
+ * The event is flushed automatically when the batch threshold or flush
63
+ * interval is reached, or when `flush()` is called explicitly. This method
64
+ * is a no-op when consent has not been granted or when `eventName` is not
65
+ * in the configured `allowedEventNames` list.
66
+ */
67
+ track(eventName: string, properties?: Record<string, unknown>): void;
68
+ /**
69
+ * Returns the current session ID, or `undefined` if consent has not been
70
+ * granted or `sessionStorage` is unavailable.
71
+ */
72
+ getSessionId(): string | undefined;
73
+ /**
74
+ * Returns the persistent anonymous visitor ID, or `undefined` if consent
75
+ * has not been granted or `localStorage` is unavailable.
76
+ */
77
+ getAnonymousId(): string | undefined;
78
+ /**
79
+ * Updates the consent state at runtime.
80
+ *
81
+ * When consent is revoked (`false`), all queued events are discarded, the
82
+ * flush timer is stopped, and all locally stored identifiers are removed.
83
+ */
84
+ setConsent(given: boolean): void;
85
+ /** Immediately sends all queued events to the ingest endpoint. */
86
+ flush(): void;
87
+ /**
88
+ * Flushes remaining events, stops the flush timer, and removes the
89
+ * page-lifecycle event listeners registered by this client instance.
90
+ */
91
+ destroy(): void;
92
+ }
93
+ /**
94
+ * Creates a new {@link FunnelClient} instance.
95
+ *
96
+ * The client batches events in memory and flushes them to the configured
97
+ * ingest endpoint when the batch size threshold is reached, on a periodic
98
+ * interval, or when `flush()` is called explicitly. In browser environments
99
+ * the client also flushes on `beforeunload` and when the tab is hidden.
100
+ *
101
+ * Event collection is disabled by default. Pass `consent: true` or call
102
+ * `setConsent(true)` after the user grants permission before tracking events.
103
+ *
104
+ * @example
105
+ * ```ts
106
+ * const client = createFunnelClient({
107
+ * apiKey: "fjs_live_abc123",
108
+ * endpoint: "https://ingest.funelr.io/v1/collect",
109
+ * consent: true,
110
+ * })
111
+ *
112
+ * client.track("page_view", { path: "/home" })
113
+ * ```
114
+ */
115
+ export declare function createFunnelClient(config: FunnelClientConfig): FunnelClient;
116
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAUA,4DAA4D;AAC5D,MAAM,WAAW,kBAAkB;IACjC;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAA;IAEhB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAErC;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IAEjB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAE1B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAA;IAE9B;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAElB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IAEtB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,+DAA+D;AAC/D,MAAM,WAAW,YAAY;IAC3B;;;;;;;OAOG;IACH,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IAEpE;;;OAGG;IACH,YAAY,IAAI,MAAM,GAAG,SAAS,CAAA;IAElC;;;OAGG;IACH,cAAc,IAAI,MAAM,GAAG,SAAS,CAAA;IAEpC;;;;;OAKG;IACH,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAA;IAEhC,kEAAkE;IAClE,KAAK,IAAI,IAAI,CAAA;IAEb;;;OAGG;IACH,OAAO,IAAI,IAAI,CAAA;CAChB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,kBAAkB,GAAG,YAAY,CAyG3E"}
package/dist/client.js ADDED
@@ -0,0 +1,123 @@
1
+ import { SDK_VERSION } from "./constants.js";
2
+ import { clearAnonymousId, clearSessionId, getOrCreateAnonymousId, getOrCreateSessionId, } from "./session.js";
3
+ import { sendBatch } from "./transport.js";
4
+ /**
5
+ * Creates a new {@link FunnelClient} instance.
6
+ *
7
+ * The client batches events in memory and flushes them to the configured
8
+ * ingest endpoint when the batch size threshold is reached, on a periodic
9
+ * interval, or when `flush()` is called explicitly. In browser environments
10
+ * the client also flushes on `beforeunload` and when the tab is hidden.
11
+ *
12
+ * Event collection is disabled by default. Pass `consent: true` or call
13
+ * `setConsent(true)` after the user grants permission before tracking events.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * const client = createFunnelClient({
18
+ * apiKey: "fjs_live_abc123",
19
+ * endpoint: "https://ingest.funelr.io/v1/collect",
20
+ * consent: true,
21
+ * })
22
+ *
23
+ * client.track("page_view", { path: "/home" })
24
+ * ```
25
+ */
26
+ export function createFunnelClient(config) {
27
+ try {
28
+ new URL(config.endpoint);
29
+ }
30
+ catch {
31
+ throw new Error(`[FunnelClient] Invalid endpoint URL: "${config.endpoint}"`);
32
+ }
33
+ let consentGiven = config.consent ?? false;
34
+ const batchSize = config.batchSize ?? 20;
35
+ const flushInterval = config.flushInterval ?? 5000;
36
+ const maxRetries = config.maxRetries ?? 3;
37
+ const queue = [];
38
+ let flushTimer;
39
+ function startTimer() {
40
+ if (flushTimer !== undefined)
41
+ return;
42
+ flushTimer = setInterval(flush, flushInterval);
43
+ }
44
+ function stopTimer() {
45
+ if (flushTimer !== undefined) {
46
+ clearInterval(flushTimer);
47
+ flushTimer = undefined;
48
+ }
49
+ }
50
+ function flush() {
51
+ if (queue.length === 0)
52
+ return;
53
+ const batch = queue.splice(0, queue.length);
54
+ sendBatch({
55
+ endpoint: config.endpoint,
56
+ events: batch,
57
+ ...(config.apiKey !== undefined ? { apiKey: config.apiKey } : {}),
58
+ maxRetries,
59
+ });
60
+ }
61
+ function getAnonymousId() {
62
+ if (!consentGiven)
63
+ return undefined;
64
+ return getOrCreateAnonymousId(config.anonymousIdStorageKey);
65
+ }
66
+ function getSessionId() {
67
+ if (!consentGiven)
68
+ return undefined;
69
+ return getOrCreateSessionId(config.sessionStorageKey);
70
+ }
71
+ function track(eventName, properties) {
72
+ if (!consentGiven)
73
+ return;
74
+ if (config.allowedEventNames && !config.allowedEventNames.includes(eventName))
75
+ return;
76
+ const anonymousId = getAnonymousId();
77
+ const sessionId = getSessionId();
78
+ const url = typeof location !== "undefined" ? location.href : undefined;
79
+ const referrer = typeof document !== "undefined" ? document.referrer || undefined : undefined;
80
+ const payload = {
81
+ eventName,
82
+ timestamp: new Date().toISOString(),
83
+ sdkVersion: SDK_VERSION,
84
+ ...(anonymousId !== undefined ? { anonymousId } : {}),
85
+ ...(sessionId !== undefined ? { sessionId } : {}),
86
+ ...(url !== undefined ? { url } : {}),
87
+ ...(referrer !== undefined ? { referrer } : {}),
88
+ ...(properties !== undefined ? { properties } : {}),
89
+ };
90
+ queue.push(payload);
91
+ startTimer();
92
+ if (queue.length >= batchSize) {
93
+ flush();
94
+ }
95
+ }
96
+ function setConsent(given) {
97
+ consentGiven = given;
98
+ if (!given) {
99
+ clearSessionId(config.sessionStorageKey);
100
+ clearAnonymousId(config.anonymousIdStorageKey);
101
+ queue.splice(0, queue.length);
102
+ stopTimer();
103
+ }
104
+ }
105
+ function onVisibilityChange() {
106
+ if (document.visibilityState === "hidden")
107
+ flush();
108
+ }
109
+ function destroy() {
110
+ flush();
111
+ stopTimer();
112
+ if (typeof window !== "undefined") {
113
+ window.removeEventListener("beforeunload", flush);
114
+ window.removeEventListener("visibilitychange", onVisibilityChange);
115
+ }
116
+ }
117
+ if (typeof window !== "undefined") {
118
+ window.addEventListener("beforeunload", flush);
119
+ window.addEventListener("visibilitychange", onVisibilityChange);
120
+ }
121
+ return { track, getSessionId, getAnonymousId, setConsent, flush, destroy };
122
+ }
123
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ /** Current version of the SDK, injected into every event payload. */
2
+ export declare const SDK_VERSION = "0.4.0";
3
+ /** Maximum allowed length for event names. */
4
+ export declare const EVENT_NAME_MAX_LENGTH = 100;
5
+ /** Maximum allowed length for session and anonymous identifier fields. */
6
+ export declare const SESSION_ID_MAX_LENGTH = 64;
7
+ /** Maximum allowed length for URL and referrer fields. */
8
+ export declare const URL_MAX_LENGTH = 2048;
9
+ /** Regular expression that matches a UUID v4 string. */
10
+ export declare const UUID_V4_REGEX: RegExp;
11
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,eAAO,MAAM,WAAW,UAAU,CAAA;AAElC,8CAA8C;AAC9C,eAAO,MAAM,qBAAqB,MAAM,CAAA;AAExC,0EAA0E;AAC1E,eAAO,MAAM,qBAAqB,KAAK,CAAA;AAEvC,0DAA0D;AAC1D,eAAO,MAAM,cAAc,OAAO,CAAA;AAElC,wDAAwD;AACxD,eAAO,MAAM,aAAa,QACgD,CAAA"}
@@ -0,0 +1,11 @@
1
+ /** Current version of the SDK, injected into every event payload. */
2
+ export const SDK_VERSION = "0.4.0";
3
+ /** Maximum allowed length for event names. */
4
+ export const EVENT_NAME_MAX_LENGTH = 100;
5
+ /** Maximum allowed length for session and anonymous identifier fields. */
6
+ export const SESSION_ID_MAX_LENGTH = 64;
7
+ /** Maximum allowed length for URL and referrer fields. */
8
+ export const URL_MAX_LENGTH = 2048;
9
+ /** Regular expression that matches a UUID v4 string. */
10
+ export const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
11
+ //# sourceMappingURL=constants.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.js","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,MAAM,CAAC,MAAM,WAAW,GAAG,OAAO,CAAA;AAElC,8CAA8C;AAC9C,MAAM,CAAC,MAAM,qBAAqB,GAAG,GAAG,CAAA;AAExC,0EAA0E;AAC1E,MAAM,CAAC,MAAM,qBAAqB,GAAG,EAAE,CAAA;AAEvC,0DAA0D;AAC1D,MAAM,CAAC,MAAM,cAAc,GAAG,IAAI,CAAA;AAElC,wDAAwD;AACxD,MAAM,CAAC,MAAM,aAAa,GACxB,wEAAwE,CAAA"}
@@ -0,0 +1,7 @@
1
+ export { createFunnelClient, type FunnelClient, type FunnelClientConfig } from "./client.js";
2
+ export { EVENT_NAME_MAX_LENGTH, SDK_VERSION, SESSION_ID_MAX_LENGTH, URL_MAX_LENGTH, UUID_V4_REGEX, } from "./constants.js";
3
+ export { type EventPayload, eventPayloadSchema } from "./schemas.js";
4
+ export type { FunnelConfig, FunnelStatsResponse, FunnelStep, FunnelStepStat, FunnelSummaryResponse, PropertyBreakdownItem, RawEvent, RawEventsResponse, SessionEvent, TopEventStat, TrendDataPoint, TrendsResponse, } from "./types.js";
5
+ export { clearAnonymousId, clearSessionId, getOrCreateAnonymousId, getOrCreateSessionId, } from "./session.js";
6
+ export { isAllowedEventName, isValidUUID } from "./validation.js";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,KAAK,YAAY,EAAE,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAC5F,OAAO,EACL,qBAAqB,EACrB,WAAW,EACX,qBAAqB,EACrB,cAAc,EACd,aAAa,GACd,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,KAAK,YAAY,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAA;AACpE,YAAY,EACV,YAAY,EACZ,mBAAmB,EACnB,UAAU,EACV,cAAc,EACd,qBAAqB,EACrB,qBAAqB,EACrB,QAAQ,EACR,iBAAiB,EACjB,YAAY,EACZ,YAAY,EACZ,cAAc,EACd,cAAc,GACf,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,GACrB,MAAM,cAAc,CAAA;AACrB,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { createFunnelClient } from "./client.js";
2
+ export { EVENT_NAME_MAX_LENGTH, SDK_VERSION, SESSION_ID_MAX_LENGTH, URL_MAX_LENGTH, UUID_V4_REGEX, } from "./constants.js";
3
+ export { eventPayloadSchema } from "./schemas.js";
4
+ export { clearAnonymousId, clearSessionId, getOrCreateAnonymousId, getOrCreateSessionId, } from "./session.js";
5
+ export { isAllowedEventName, isValidUUID } from "./validation.js";
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAA8C,MAAM,aAAa,CAAA;AAC5F,OAAO,EACL,qBAAqB,EACrB,WAAW,EACX,qBAAqB,EACrB,cAAc,EACd,aAAa,GACd,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAqB,kBAAkB,EAAE,MAAM,cAAc,CAAA;AAepE,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,GACrB,MAAM,cAAc,CAAA;AACrB,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA"}
@@ -0,0 +1,46 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Zod schema for validating an ingest event payload.
4
+ *
5
+ * Mirrors the server-side contract accepted by `POST /v1/collect/batch`.
6
+ * Use this schema on the server or in tests to assert payload correctness.
7
+ */
8
+ export declare const eventPayloadSchema: z.ZodObject<{
9
+ /** Name of the event, e.g. `"page_view"` or `"cta_click"`. */
10
+ eventName: z.ZodString;
11
+ /** Persistent visitor identifier stored in `localStorage` (post-consent). */
12
+ anonymousId: z.ZodOptional<z.ZodString>;
13
+ /** Session-scoped identifier stored in `sessionStorage`. */
14
+ sessionId: z.ZodOptional<z.ZodString>;
15
+ /** Full URL of the page where the event occurred. */
16
+ url: z.ZodOptional<z.ZodString>;
17
+ /** HTTP referrer. An empty string is accepted when no referrer is present. */
18
+ referrer: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
19
+ /** Arbitrary key/value properties attached to the event. */
20
+ properties: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
21
+ /** ISO 8601 timestamp recorded on the client at the time of the event. */
22
+ timestamp: z.ZodOptional<z.ZodString>;
23
+ /** Version string of the SDK that produced this payload, e.g. `"0.3.0"`. */
24
+ sdkVersion: z.ZodOptional<z.ZodString>;
25
+ }, "strip", z.ZodTypeAny, {
26
+ eventName: string;
27
+ anonymousId?: string | undefined;
28
+ sessionId?: string | undefined;
29
+ url?: string | undefined;
30
+ referrer?: string | undefined;
31
+ properties?: Record<string, unknown> | undefined;
32
+ timestamp?: string | undefined;
33
+ sdkVersion?: string | undefined;
34
+ }, {
35
+ eventName: string;
36
+ anonymousId?: string | undefined;
37
+ sessionId?: string | undefined;
38
+ url?: string | undefined;
39
+ referrer?: string | undefined;
40
+ properties?: Record<string, unknown> | undefined;
41
+ timestamp?: string | undefined;
42
+ sdkVersion?: string | undefined;
43
+ }>;
44
+ /** Ingest event payload — the unit of data sent to `POST /v1/collect/batch`. */
45
+ export type EventPayload = z.infer<typeof eventPayloadSchema>;
46
+ //# sourceMappingURL=schemas.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAGvB;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB;IAC7B,8DAA8D;;IAE9D,6EAA6E;;IAE7E,4DAA4D;;IAE5D,qDAAqD;;IAErD,8EAA8E;;IAE9E,4DAA4D;;IAE5D,0EAA0E;;IAE1E,4EAA4E;;;;;;;;;;;;;;;;;;;;EAE5E,CAAA;AAEF,gFAAgF;AAChF,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAA"}
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+ import { EVENT_NAME_MAX_LENGTH, SESSION_ID_MAX_LENGTH, URL_MAX_LENGTH } from "./constants.js";
3
+ /**
4
+ * Zod schema for validating an ingest event payload.
5
+ *
6
+ * Mirrors the server-side contract accepted by `POST /v1/collect/batch`.
7
+ * Use this schema on the server or in tests to assert payload correctness.
8
+ */
9
+ export const eventPayloadSchema = z.object({
10
+ /** Name of the event, e.g. `"page_view"` or `"cta_click"`. */
11
+ eventName: z.string().min(1).max(EVENT_NAME_MAX_LENGTH),
12
+ /** Persistent visitor identifier stored in `localStorage` (post-consent). */
13
+ anonymousId: z.string().min(1).max(SESSION_ID_MAX_LENGTH).optional(),
14
+ /** Session-scoped identifier stored in `sessionStorage`. */
15
+ sessionId: z.string().max(SESSION_ID_MAX_LENGTH).optional(),
16
+ /** Full URL of the page where the event occurred. */
17
+ url: z.string().url().max(URL_MAX_LENGTH).optional(),
18
+ /** HTTP referrer. An empty string is accepted when no referrer is present. */
19
+ referrer: z.string().url().max(URL_MAX_LENGTH).optional().or(z.literal("")),
20
+ /** Arbitrary key/value properties attached to the event. */
21
+ properties: z.record(z.string(), z.unknown()).optional(),
22
+ /** ISO 8601 timestamp recorded on the client at the time of the event. */
23
+ timestamp: z.string().datetime().optional(),
24
+ /** Version string of the SDK that produced this payload, e.g. `"0.3.0"`. */
25
+ sdkVersion: z.string().optional(),
26
+ });
27
+ //# sourceMappingURL=schemas.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schemas.js","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AAE7F;;;;;GAKG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IACzC,8DAA8D;IAC9D,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,qBAAqB,CAAC;IACvD,6EAA6E;IAC7E,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC,QAAQ,EAAE;IACpE,4DAA4D;IAC5D,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC,QAAQ,EAAE;IAC3D,qDAAqD;IACrD,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC,QAAQ,EAAE;IACpD,8EAA8E;IAC9E,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC3E,4DAA4D;IAC5D,UAAU,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,QAAQ,EAAE;IACxD,0EAA0E;IAC1E,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE;IAC3C,4EAA4E;IAC5E,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;CAClC,CAAC,CAAA"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Returns the session ID stored under `key`, creating and persisting a new
3
+ * UUID v4 if none exists. Returns `undefined` when called outside a browser
4
+ * context (e.g. SSR).
5
+ *
6
+ * The ID is scoped to the current browser tab via `sessionStorage` and is
7
+ * cleared automatically when the tab is closed.
8
+ */
9
+ export declare function getOrCreateSessionId(key?: string): string | undefined;
10
+ /**
11
+ * Removes the session ID from `sessionStorage`.
12
+ *
13
+ * Call this when the user revokes tracking consent.
14
+ */
15
+ export declare function clearSessionId(key?: string): void;
16
+ /**
17
+ * Returns the persistent anonymous visitor ID stored under `key`, creating
18
+ * and persisting a new UUID v4 if none exists. Returns `undefined` outside a
19
+ * browser context or when `localStorage` is unavailable.
20
+ *
21
+ * The ID is stored in `localStorage` and persists across sessions.
22
+ * It must only be created after the user has granted consent.
23
+ */
24
+ export declare function getOrCreateAnonymousId(key?: string): string | undefined;
25
+ /**
26
+ * Removes the anonymous visitor ID from `localStorage`.
27
+ *
28
+ * Call this when the user revokes tracking consent.
29
+ */
30
+ export declare function clearAnonymousId(key?: string): void;
31
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AASA;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUrE;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAGjD;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUvE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAGnD"}
@@ -0,0 +1,64 @@
1
+ const DEFAULT_SESSION_KEY = "funnel_session_id";
2
+ const DEFAULT_ANONYMOUS_KEY = "funnel_anonymous_id";
3
+ function isBrowser() {
4
+ return (typeof globalThis.crypto !== "undefined" && typeof globalThis.sessionStorage !== "undefined");
5
+ }
6
+ /**
7
+ * Returns the session ID stored under `key`, creating and persisting a new
8
+ * UUID v4 if none exists. Returns `undefined` when called outside a browser
9
+ * context (e.g. SSR).
10
+ *
11
+ * The ID is scoped to the current browser tab via `sessionStorage` and is
12
+ * cleared automatically when the tab is closed.
13
+ */
14
+ export function getOrCreateSessionId(key) {
15
+ if (!isBrowser())
16
+ return undefined;
17
+ const storageKey = key ?? DEFAULT_SESSION_KEY;
18
+ const existing = sessionStorage.getItem(storageKey);
19
+ if (existing)
20
+ return existing;
21
+ const id = crypto.randomUUID();
22
+ sessionStorage.setItem(storageKey, id);
23
+ return id;
24
+ }
25
+ /**
26
+ * Removes the session ID from `sessionStorage`.
27
+ *
28
+ * Call this when the user revokes tracking consent.
29
+ */
30
+ export function clearSessionId(key) {
31
+ if (!isBrowser())
32
+ return;
33
+ sessionStorage.removeItem(key ?? DEFAULT_SESSION_KEY);
34
+ }
35
+ /**
36
+ * Returns the persistent anonymous visitor ID stored under `key`, creating
37
+ * and persisting a new UUID v4 if none exists. Returns `undefined` outside a
38
+ * browser context or when `localStorage` is unavailable.
39
+ *
40
+ * The ID is stored in `localStorage` and persists across sessions.
41
+ * It must only be created after the user has granted consent.
42
+ */
43
+ export function getOrCreateAnonymousId(key) {
44
+ if (!isBrowser() || typeof localStorage === "undefined")
45
+ return undefined;
46
+ const storageKey = key ?? DEFAULT_ANONYMOUS_KEY;
47
+ const existing = localStorage.getItem(storageKey);
48
+ if (existing)
49
+ return existing;
50
+ const id = crypto.randomUUID();
51
+ localStorage.setItem(storageKey, id);
52
+ return id;
53
+ }
54
+ /**
55
+ * Removes the anonymous visitor ID from `localStorage`.
56
+ *
57
+ * Call this when the user revokes tracking consent.
58
+ */
59
+ export function clearAnonymousId(key) {
60
+ if (!isBrowser() || typeof localStorage === "undefined")
61
+ return;
62
+ localStorage.removeItem(key ?? DEFAULT_ANONYMOUS_KEY);
63
+ }
64
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,MAAM,mBAAmB,GAAG,mBAAmB,CAAA;AAC/C,MAAM,qBAAqB,GAAG,qBAAqB,CAAA;AAEnD,SAAS,SAAS;IAChB,OAAO,CACL,OAAO,UAAU,CAAC,MAAM,KAAK,WAAW,IAAI,OAAO,UAAU,CAAC,cAAc,KAAK,WAAW,CAC7F,CAAA;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB,CAAC,GAAY;IAC/C,IAAI,CAAC,SAAS,EAAE;QAAE,OAAO,SAAS,CAAA;IAElC,MAAM,UAAU,GAAG,GAAG,IAAI,mBAAmB,CAAA;IAC7C,MAAM,QAAQ,GAAG,cAAc,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;IACnD,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAA;IAE7B,MAAM,EAAE,GAAG,MAAM,CAAC,UAAU,EAAE,CAAA;IAC9B,cAAc,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAA;IACtC,OAAO,EAAE,CAAA;AACX,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,GAAY;IACzC,IAAI,CAAC,SAAS,EAAE;QAAE,OAAM;IACxB,cAAc,CAAC,UAAU,CAAC,GAAG,IAAI,mBAAmB,CAAC,CAAA;AACvD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,sBAAsB,CAAC,GAAY;IACjD,IAAI,CAAC,SAAS,EAAE,IAAI,OAAO,YAAY,KAAK,WAAW;QAAE,OAAO,SAAS,CAAA;IAEzE,MAAM,UAAU,GAAG,GAAG,IAAI,qBAAqB,CAAA;IAC/C,MAAM,QAAQ,GAAG,YAAY,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;IACjD,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAA;IAE7B,MAAM,EAAE,GAAG,MAAM,CAAC,UAAU,EAAE,CAAA;IAC9B,YAAY,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAA;IACpC,OAAO,EAAE,CAAA;AACX,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAY;IAC3C,IAAI,CAAC,SAAS,EAAE,IAAI,OAAO,YAAY,KAAK,WAAW;QAAE,OAAM;IAC/D,YAAY,CAAC,UAAU,CAAC,GAAG,IAAI,qBAAqB,CAAC,CAAA;AACvD,CAAC"}
@@ -0,0 +1,23 @@
1
+ import type { EventPayload } from "./schemas.js";
2
+ /** Options for {@link sendBatch}. */
3
+ export interface BatchSendOptions {
4
+ /** Base endpoint URL, e.g. `"https://ingest.funelr.io/v1/collect"`. */
5
+ endpoint: string;
6
+ /** Events to deliver. */
7
+ events: EventPayload[];
8
+ /** API key sent as the `X-Api-Key` request header. */
9
+ apiKey?: string;
10
+ /** Maximum retry attempts on network errors or 5xx responses. Defaults to `3`. */
11
+ maxRetries?: number;
12
+ }
13
+ /**
14
+ * Sends a batch of events to `{endpoint}/batch` via `POST`.
15
+ *
16
+ * For single-event flushes the function attempts `navigator.sendBeacon` first
17
+ * to guarantee delivery during page unload. Multi-event batches fall back to
18
+ * `fetch` with exponential-backoff retries on network errors and 5xx responses.
19
+ *
20
+ * Delivery failures are intentionally silent — event tracking is best-effort.
21
+ */
22
+ export declare function sendBatch({ endpoint, events, apiKey, maxRetries }: BatchSendOptions): void;
23
+ //# sourceMappingURL=transport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAEhD,qCAAqC;AACrC,MAAM,WAAW,gBAAgB;IAC/B,uEAAuE;IACvE,QAAQ,EAAE,MAAM,CAAA;IAChB,yBAAyB;IACzB,MAAM,EAAE,YAAY,EAAE,CAAA;IACtB,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AA4BD;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,UAAc,EAAE,EAAE,gBAAgB,GAAG,IAAI,CAe9F"}
@@ -0,0 +1,43 @@
1
+ function backoff(attempt) {
2
+ return new Promise((resolve) => setTimeout(resolve, Math.min(1000 * 2 ** attempt, 30_000)));
3
+ }
4
+ async function sendWithRetry(endpoint, body, headers, maxRetries, attempt = 0) {
5
+ try {
6
+ const res = await fetch(endpoint, { method: "POST", headers, body, keepalive: true });
7
+ if (!res.ok && res.status >= 500 && attempt < maxRetries) {
8
+ await backoff(attempt);
9
+ return sendWithRetry(endpoint, body, headers, maxRetries, attempt + 1);
10
+ }
11
+ }
12
+ catch {
13
+ if (attempt < maxRetries) {
14
+ await backoff(attempt);
15
+ return sendWithRetry(endpoint, body, headers, maxRetries, attempt + 1);
16
+ }
17
+ }
18
+ }
19
+ /**
20
+ * Sends a batch of events to `{endpoint}/batch` via `POST`.
21
+ *
22
+ * For single-event flushes the function attempts `navigator.sendBeacon` first
23
+ * to guarantee delivery during page unload. Multi-event batches fall back to
24
+ * `fetch` with exponential-backoff retries on network errors and 5xx responses.
25
+ *
26
+ * Delivery failures are intentionally silent — event tracking is best-effort.
27
+ */
28
+ export function sendBatch({ endpoint, events, apiKey, maxRetries = 3 }) {
29
+ if (events.length === 0)
30
+ return;
31
+ const body = JSON.stringify({ events });
32
+ const headers = { "Content-Type": "application/json" };
33
+ if (apiKey)
34
+ headers["X-Api-Key"] = apiKey;
35
+ const batchEndpoint = `${endpoint}/batch`;
36
+ if (events.length === 1 && typeof navigator?.sendBeacon === "function") {
37
+ const blob = new Blob([body], { type: "application/json" });
38
+ if (navigator.sendBeacon(batchEndpoint, blob))
39
+ return;
40
+ }
41
+ sendWithRetry(batchEndpoint, body, headers, maxRetries).catch(() => { });
42
+ }
43
+ //# sourceMappingURL=transport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAeA,SAAS,OAAO,CAAC,OAAe;IAC9B,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,IAAI,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,CAAA;AAC7F,CAAC;AAED,KAAK,UAAU,aAAa,CAC1B,QAAgB,EAChB,IAAY,EACZ,OAA+B,EAC/B,UAAkB,EAClB,OAAO,GAAG,CAAC;IAEX,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,QAAQ,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;QACrF,IAAI,CAAC,GAAG,CAAC,EAAE,IAAI,GAAG,CAAC,MAAM,IAAI,GAAG,IAAI,OAAO,GAAG,UAAU,EAAE,CAAC;YACzD,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;YACtB,OAAO,aAAa,CAAC,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,UAAU,EAAE,OAAO,GAAG,CAAC,CAAC,CAAA;QACxE,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,IAAI,OAAO,GAAG,UAAU,EAAE,CAAC;YACzB,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;YACtB,OAAO,aAAa,CAAC,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,UAAU,EAAE,OAAO,GAAG,CAAC,CAAC,CAAA;QACxE,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,SAAS,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,CAAC,EAAoB;IACtF,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAM;IAE/B,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC,CAAA;IACvC,MAAM,OAAO,GAA2B,EAAE,cAAc,EAAE,kBAAkB,EAAE,CAAA;IAC9E,IAAI,MAAM;QAAE,OAAO,CAAC,WAAW,CAAC,GAAG,MAAM,CAAA;IAEzC,MAAM,aAAa,GAAG,GAAG,QAAQ,QAAQ,CAAA;IAEzC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,IAAI,OAAO,SAAS,EAAE,UAAU,KAAK,UAAU,EAAE,CAAC;QACvE,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,EAAE,kBAAkB,EAAE,CAAC,CAAA;QAC3D,IAAI,SAAS,CAAC,UAAU,CAAC,aAAa,EAAE,IAAI,CAAC;YAAE,OAAM;IACvD,CAAC;IAED,aAAa,CAAC,aAAa,EAAE,IAAI,EAAE,OAAO,EAAE,UAAU,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;AACzE,CAAC"}
@@ -0,0 +1,96 @@
1
+ /** A single step in a funnel definition. */
2
+ export interface FunnelStep {
3
+ /** Event name that represents reaching this step. */
4
+ eventName: string;
5
+ /** Human-readable label shown in the dashboard. */
6
+ label: string;
7
+ }
8
+ /** Configuration for a funnel, as returned by the funelr.io API. */
9
+ export interface FunnelConfig {
10
+ /** UUID of the project this funnel belongs to. */
11
+ siteId: string;
12
+ /** Ordered list of steps that define the funnel. */
13
+ steps: FunnelStep[];
14
+ }
15
+ /** Response from `GET /v1/stats/:projectId/summary`. */
16
+ export interface FunnelSummaryResponse {
17
+ /** Start of the requested period (ISO 8601). */
18
+ from: string;
19
+ /** End of the requested period (ISO 8601). */
20
+ to: string;
21
+ /** Number of unique `anonymous_id` values observed in the period. */
22
+ visitors: number;
23
+ /** Total event count keyed by `event_name`. */
24
+ eventTotals: Record<string, number>;
25
+ }
26
+ /** A single step entry within a {@link FunnelStatsResponse}. */
27
+ export interface FunnelStepStat {
28
+ step_order: number;
29
+ name: string;
30
+ event_name: string;
31
+ users_reached: number;
32
+ /** Conversion rate relative to the first step, or `null` for the first step itself. */
33
+ conversion_from_first: number | null;
34
+ /** Conversion rate relative to the previous step, or `null` for the first step. */
35
+ conversion_from_prev: number | null;
36
+ }
37
+ /** Response from `GET /v1/stats/:projectId/funnels/:funnelId`. */
38
+ export interface FunnelStatsResponse {
39
+ funnelId: string;
40
+ funnelName: string;
41
+ from: string;
42
+ to: string;
43
+ overallConversionRate: number;
44
+ steps: FunnelStepStat[];
45
+ }
46
+ /** A single time-series data point within a {@link TrendsResponse}. */
47
+ export interface TrendDataPoint {
48
+ /** Bucket start time (ISO 8601). */
49
+ bucket: string;
50
+ event_count: number;
51
+ unique_visitors: number;
52
+ }
53
+ /** Response from `GET /v1/stats/:projectId/trends`. */
54
+ export interface TrendsResponse {
55
+ series: Array<{
56
+ eventName: string;
57
+ data: TrendDataPoint[];
58
+ }>;
59
+ }
60
+ /** A single row from `GET /analytics/:projectId/events/top`. */
61
+ export interface TopEventStat {
62
+ event_name: string;
63
+ total_count: number;
64
+ unique_visitors: number;
65
+ }
66
+ /** A single row from `GET /analytics/:projectId/property-breakdown`. */
67
+ export interface PropertyBreakdownItem {
68
+ value: string;
69
+ count: number;
70
+ }
71
+ /** A single event record from `GET /analytics/:projectId/raw-events`. */
72
+ export interface RawEvent {
73
+ id: string;
74
+ event_name: string;
75
+ anonymous_id: string;
76
+ properties: Record<string, unknown> | null;
77
+ server_received_at: string;
78
+ }
79
+ /** Response from `GET /analytics/:projectId/raw-events`. */
80
+ export interface RawEventsResponse {
81
+ events: RawEvent[];
82
+ total: number;
83
+ limit: number;
84
+ offset: number;
85
+ }
86
+ /** A single event record from `GET /analytics/:projectId/sessions/:anonymousId/events`. */
87
+ export interface SessionEvent {
88
+ id: string;
89
+ event_name: string;
90
+ properties: Record<string, unknown> | null;
91
+ url: string | null;
92
+ referrer: string | null;
93
+ server_received_at: string;
94
+ client_sent_at: string | null;
95
+ }
96
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Convention: nullable fields in API response types use `null` (mirrors JSON).
3
+ // Optional fields in request/config types use `undefined` (TypeScript idiom).
4
+ // ---------------------------------------------------------------------------
5
+ export {};
6
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,+EAA+E;AAC/E,8EAA8E;AAC9E,8EAA8E"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Returns `true` when `value` is a well-formed UUID v4 string.
3
+ *
4
+ * @example
5
+ * isValidUUID("550e8400-e29b-41d4-a716-446655440000") // true
6
+ * isValidUUID("not-a-uuid") // false
7
+ */
8
+ export declare function isValidUUID(value: string): boolean;
9
+ /**
10
+ * Returns `true` when `name` is a non-empty string within the allowed length
11
+ * and, if `allowedNames` is provided, is present in that allowlist.
12
+ *
13
+ * @example
14
+ * isAllowedEventName("page_view") // true (no allowlist)
15
+ * isAllowedEventName("page_view", ["page_view", "click"]) // true
16
+ * isAllowedEventName("unknown", ["page_view", "click"]) // false
17
+ */
18
+ export declare function isAllowedEventName(name: string, allowedNames?: readonly string[]): boolean;
19
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAElD;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAI1F"}
@@ -0,0 +1,28 @@
1
+ import { EVENT_NAME_MAX_LENGTH, UUID_V4_REGEX } from "./constants.js";
2
+ /**
3
+ * Returns `true` when `value` is a well-formed UUID v4 string.
4
+ *
5
+ * @example
6
+ * isValidUUID("550e8400-e29b-41d4-a716-446655440000") // true
7
+ * isValidUUID("not-a-uuid") // false
8
+ */
9
+ export function isValidUUID(value) {
10
+ return UUID_V4_REGEX.test(value);
11
+ }
12
+ /**
13
+ * Returns `true` when `name` is a non-empty string within the allowed length
14
+ * and, if `allowedNames` is provided, is present in that allowlist.
15
+ *
16
+ * @example
17
+ * isAllowedEventName("page_view") // true (no allowlist)
18
+ * isAllowedEventName("page_view", ["page_view", "click"]) // true
19
+ * isAllowedEventName("unknown", ["page_view", "click"]) // false
20
+ */
21
+ export function isAllowedEventName(name, allowedNames) {
22
+ if (name.length === 0 || name.length > EVENT_NAME_MAX_LENGTH)
23
+ return false;
24
+ if (!allowedNames)
25
+ return true;
26
+ return allowedNames.includes(name);
27
+ }
28
+ //# sourceMappingURL=validation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.js","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAErE;;;;;;GAMG;AACH,MAAM,UAAU,WAAW,CAAC,KAAa;IACvC,OAAO,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;AAClC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAY,EAAE,YAAgC;IAC/E,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,MAAM,GAAG,qBAAqB;QAAE,OAAO,KAAK,CAAA;IAC1E,IAAI,CAAC,YAAY;QAAE,OAAO,IAAI,CAAA;IAC9B,OAAO,YAAY,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAA;AACpC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@funelr/events",
3
+ "version": "0.4.0",
4
+ "description": "Official event tracking SDK for funelr.io",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "scripts": {
16
+ "prepare": "npm run build",
17
+ "build": "tsc -p tsconfig.build.json",
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest",
21
+ "lint": "biome check .",
22
+ "lint:fix": "biome check --write .",
23
+ "clean": "rm -rf dist *.tsbuildinfo"
24
+ },
25
+ "dependencies": {
26
+ "zod": "^3.24.0"
27
+ },
28
+ "devDependencies": {
29
+ "@biomejs/biome": "^2.0.0",
30
+ "@vitest/coverage-v8": "^3.2.4",
31
+ "jsdom": "^28.1.0",
32
+ "typescript": "^5.7.0",
33
+ "vitest": "^3.0.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/funelr/events-sdk.git"
44
+ },
45
+ "keywords": [
46
+ "analytics",
47
+ "funnel",
48
+ "tracking",
49
+ "events",
50
+ "sdk"
51
+ ],
52
+ "license": "MIT"
53
+ }