fimo-analytics 2.7.0-experimental.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 ADDED
@@ -0,0 +1,242 @@
1
+ # fimo-analytics
2
+
3
+ Fimo pageviews for existing websites. One framework-free tracker, with optional
4
+ framework adapters. No framework peer is needed for JavaScript or plain HTML.
5
+
6
+ This source introduces the package. Installation from npm requires a release
7
+ that contains it; source presence is not evidence of publication. The package
8
+ releases with the Fimo package group, but the standalone API has no dependency
9
+ on the Fimo CLI or a hosting adapter.
10
+
11
+ ## Connection
12
+
13
+ ```sh
14
+ pnpm add fimo-analytics
15
+ ```
16
+
17
+ Configure the complete ingestion URL, including `/e/p`, for your project and
18
+ branch. Use the tenant API base URL supplied by your Fimo connection and append
19
+ `/e/p`; do not substitute your site's public URL or guess a tenant hostname.
20
+ An origin-relative endpoint such as `/analytics-proxy/e/p` requires a proxy you
21
+ already configured. There is no endpoint-discovery API in this package.
22
+
23
+ ```ts
24
+ import { init } from 'fimo-analytics';
25
+
26
+ const analytics = init({
27
+ endpoint: 'https://YOUR-TENANT-API-HOST/e/p',
28
+ enabled: true,
29
+ });
30
+ // When the application that owns the tracker is disposed:
31
+ analytics.destroy();
32
+ ```
33
+
34
+ Standalone tracking is enabled by default. Pass `enabled: false` in development
35
+ and preview builds. Every send also checks for `bl_preview_token` in the current
36
+ URL. Disabled and server-side initialization is inert, with no storage or
37
+ network effects. Invalid active configuration throws before sending.
38
+
39
+ ## Framework adapters
40
+
41
+ Install only the peers for your framework. Every component accepts `endpoint`
42
+ and `enabled`, renders no visible UI, and cleans up when removed. Put it in a
43
+ persistent application root. Generic React, Svelte and Vue observe browser
44
+ history. Framework router adapters emit after committed navigation.
45
+
46
+ ### React and React Router
47
+
48
+ ```tsx
49
+ import { Analytics } from 'fimo-analytics/react';
50
+ // React Router: import { Analytics } from 'fimo-analytics/react-router';
51
+
52
+ <Analytics endpoint={endpoint} enabled={isProduction} />;
53
+ ```
54
+
55
+ The React Router component goes inside the router. It also accepts an optional
56
+ `pageName`. It reads the router location, including virtual HashRouter paths.
57
+
58
+ The upgraded `fimo-react-router` still exposes `Pageview({ routes })` and
59
+ `FimoProviders` still mounts it. They retain `VITE_API_URL`,
60
+ `VITE_ENABLE_ANALYTICS=true`, route-name matching and preview guards. No extra
61
+ component is needed when that integration already tracks your application.
62
+
63
+ ### Next.js App and Pages routers
64
+
65
+ ```tsx
66
+ import { Analytics } from 'fimo-analytics/next';
67
+
68
+ // App Router: include this in app/layout.tsx, inside <body>.
69
+ // Pages Router: include it in pages/_app.tsx beside <Component {...pageProps} />.
70
+ <Analytics endpoint={endpoint} enabled={isProduction} />;
71
+ ```
72
+
73
+ The adapter owns the client boundary and Suspense boundary for static App
74
+ Router pages. Pages Router uses successful route-completion events. Both track
75
+ query-only navigation; prefetching does not create pageviews.
76
+
77
+ For App Router, keep one installation in the root layout. It can commit an
78
+ intermediate URL before a server redirect reaches its destination. Both URLs can
79
+ produce pageviews. Destination-only counting is not guaranteed for these redirects.
80
+ Pages Router uses a different completion signal and does not share this limitation
81
+ in the tested redirect journey.
82
+
83
+ ### Astro
84
+
85
+ ```astro
86
+ ---
87
+ import Analytics from 'fimo-analytics/astro';
88
+ const endpoint = 'https://YOUR-TENANT-API-HOST/e/p';
89
+ ---
90
+ <html>
91
+ <body>
92
+ <slot />
93
+ <Analytics endpoint={endpoint} enabled={import.meta.env.PROD} />
94
+ </body>
95
+ </html>
96
+ ```
97
+
98
+ Use your shared layout. No React island or `client:*` directive is needed.
99
+ Ordinary documents and Astro ClientRouter navigation are supported.
100
+
101
+ ### Svelte and SvelteKit
102
+
103
+ ```svelte
104
+ <script>
105
+ import Analytics from 'fimo-analytics/svelte';
106
+ // SvelteKit: import Analytics from 'fimo-analytics/sveltekit';
107
+ const endpoint = 'https://YOUR-TENANT-API-HOST/e/p';
108
+ </script>
109
+
110
+ <Analytics {endpoint} enabled={import.meta.env.PROD} />
111
+ ```
112
+
113
+ Use the root component, or `src/routes/+layout.svelte` in SvelteKit.
114
+ The SvelteKit adapter uses `afterNavigate` and does not emit during SSR.
115
+
116
+ ### Vue
117
+
118
+ ```vue
119
+ <script setup>
120
+ import { Analytics } from 'fimo-analytics/vue';
121
+ const endpoint = 'https://YOUR-TENANT-API-HOST/e/p';
122
+ </script>
123
+
124
+ <template><Analytics :endpoint="endpoint" /></template>
125
+ ```
126
+
127
+ For Vue Router, use the manual core in your root component instead of mounting
128
+ the generic history component. Wait for the initial router navigation before
129
+ mounting the application, as in `await router.isReady()`.
130
+
131
+ ```ts
132
+ import { onMounted, onUnmounted } from 'vue';
133
+ import { useRouter } from 'vue-router';
134
+ import { init, type Analytics } from 'fimo-analytics';
135
+
136
+ const router = useRouter();
137
+ let analytics: Analytics | undefined;
138
+ let remove: (() => void) | undefined;
139
+ onMounted(() => {
140
+ analytics = init({ endpoint, routing: 'manual', enabled: isProduction });
141
+ analytics.pageview();
142
+ remove = router.afterEach((_to, _from, failure) => {
143
+ if (!failure) analytics?.pageview();
144
+ });
145
+ });
146
+ onUnmounted(() => {
147
+ remove?.();
148
+ analytics?.destroy();
149
+ });
150
+ ```
151
+
152
+ ### Nuxt
153
+
154
+ The Nuxt module also needs the optional `@nuxt/kit` peer.
155
+
156
+ ```sh
157
+ pnpm add -D @nuxt/kit
158
+ ```
159
+
160
+ ```ts
161
+ // nuxt.config.ts
162
+ export default defineNuxtConfig({
163
+ modules: ['fimo-analytics/nuxt'],
164
+ analytics: {
165
+ endpoint: 'https://YOUR-TENANT-API-HOST/e/p',
166
+ enabled: process.env.NODE_ENV === 'production',
167
+ },
168
+ });
169
+ ```
170
+
171
+ The module installs a client-only plugin, tracks successful router navigation
172
+ including reused pages and query changes, and cleans up during application
173
+ disposal. Its configuration is public browser data. Never put secrets in it.
174
+
175
+ ### Plain HTML
176
+
177
+ Copy `node_modules/fimo-analytics/dist/browser.js` to your site's static assets,
178
+ then reference that file. Keep the copied asset pinned to the installed package.
179
+
180
+ ```html
181
+ <script defer src="/fimo-analytics.js" data-endpoint="https://YOUR-TENANT-API-HOST/e/p" data-enabled="true"></script>
182
+ ```
183
+
184
+ This is a classic script, not a module script. It uses its own `data-endpoint`
185
+ and the same core. Use `data-enabled="false"` for disabled builds. No CDN or
186
+ server-response injection is configured automatically.
187
+
188
+ ## Manual navigation and lifetime
189
+
190
+ ```ts
191
+ const analytics = init({ endpoint, routing: 'manual' });
192
+ // Call only after the application's router commits navigation.
193
+ analytics.pageview({ path: '/pricing?plan=team', pageName: 'Pricing' });
194
+ analytics.destroy();
195
+ ```
196
+
197
+ History mode is the default. It sends initially and observes `pushState`,
198
+ `replaceState` and `popstate`. Manual mode sends only when called. Default paths
199
+ include pathname and query; fragments are excluded. Default page names use the
200
+ pathname without a trailing slash. Explicit virtual paths support hash routers.
201
+
202
+ Consecutive same-path sends collapse across mounts and installed copies for the
203
+ same endpoint. `/a → /b → /a` counts three. Different active routing modes for
204
+ one endpoint throw. `destroy()` releases one handle, is safe to repeat, and
205
+ makes that handle inert. Last-handle disposal removes listeners and owned
206
+ history wrappers. Same-path remounts remain deduplicated for the document's
207
+ lifetime. Reloads and BFCache restores count as fresh visits.
208
+
209
+ ## Compatibility and delivery
210
+
211
+ The package preserves `fimo.analytics.visitor-id` in localStorage and
212
+ `fimo.analytics.session-id` in sessionStorage, without an inactivity timeout.
213
+ Blocked storage falls back to stable IDs for this browser document, shared
214
+ across copies. A later full page load may receive new IDs when storage is blocked.
215
+
216
+ Requests use the existing URL-encoded `p,n,v,s,r,u` fields, document referrer,
217
+ user agent, keepalive, CORS, and omitted credentials. Query parameters remain
218
+ available for server-side campaign attribution. Project and branch are selected
219
+ by the configured tenant endpoint. This package adds no custom events or schema.
220
+ Delivery is best effort, with no retry after a network error, 429 or 202. A
221
+ successful HTTP response is not proof that the destination stored the event.
222
+
223
+ Earlier immutable applications keep their existing tracker and reporting.
224
+ Older Fimo releases do not gain these exports or instrument missing trackers
225
+ merely because this package is released. Never add a standalone tracker beside
226
+ an unmodified legacy sender; the legacy sender cannot join the shared registry.
227
+ Older template-based projects keep their copied sender until a supported
228
+ upgrade changes their dependencies. The package does not add or remove endpoint
229
+ aliases, alter historical rows, or change retention.
230
+
231
+ ## Version qualification and validation
232
+
233
+ The fixture matrix pins React 19.2.4, React Router 7.18.2, Next.js 16.3.3,
234
+ Astro 7.2.2, Svelte 5.56.9, SvelteKit 2.70.3, Vue 3.5.42, Vue Router 5.3.1,
235
+ and Nuxt 4.5.2. Peer ranges express intended compatibility; they do not mean
236
+ that every old release in those ranges has been browser-tested. In particular,
237
+ React 18, React Router 8 and Next 15 require their own acceptance evidence before
238
+ claiming tested support. Install a release that actually contains these exports.
239
+
240
+ See [the fixture instructions](test/README.md) in the source checkout for exact
241
+ build, tarball smoke, bundle measurement and browser commands. Nothing here
242
+ asserts that the package has been npm-published or verified on a customer site.
@@ -0,0 +1,33 @@
1
+ ---
2
+ import type { AnalyticsProps } from '../dist/index.js';
3
+ interface Props extends AnalyticsProps {}
4
+ const { endpoint, enabled = true } = Astro.props;
5
+ ---
6
+ <fimo-analytics data-endpoint={endpoint} data-enabled={String(enabled)} hidden></fimo-analytics>
7
+ <script>
8
+ import { type Analytics } from '../dist/index.js';
9
+ import { initOrWarn } from '../dist/internal.js';
10
+ if (!customElements.get('fimo-analytics')) {
11
+ customElements.define('fimo-analytics', class extends HTMLElement {
12
+ tracker?: Analytics;
13
+ commit = () => {
14
+ document.removeEventListener('astro:page-load', this.commit);
15
+ this.tracker?.pageview();
16
+ };
17
+ connectedCallback() {
18
+ this.tracker = initOrWarn({ endpoint: this.dataset.endpoint!, enabled: this.dataset.enabled !== 'false', routing: 'manual' });
19
+ // FIMO-2558: after-swap has the committed URL. Page-load can be delayed by
20
+ // scripts until another navigation commits, so use it only for initial mounting.
21
+ document.addEventListener('astro:after-swap', this.commit);
22
+ document.addEventListener('astro:page-load', this.commit, { once: true });
23
+ // Static pages have no router lifecycle.
24
+ if (!document.querySelector('meta[name="astro-view-transitions-enabled"]')) this.commit();
25
+ }
26
+ disconnectedCallback() {
27
+ document.removeEventListener('astro:after-swap', this.commit);
28
+ document.removeEventListener('astro:page-load', this.commit);
29
+ this.tracker?.destroy();
30
+ }
31
+ });
32
+ }
33
+ </script>
@@ -0,0 +1,9 @@
1
+ <script lang="ts">
2
+ import { type AnalyticsProps } from '../dist/index.js';
3
+ import { initOrWarn } from '../dist/internal.js';
4
+ let { endpoint, enabled = true }: AnalyticsProps = $props();
5
+ $effect(() => {
6
+ const tracker = initOrWarn({ endpoint, enabled });
7
+ return () => tracker.destroy();
8
+ });
9
+ </script>
@@ -0,0 +1,16 @@
1
+ <script lang="ts">
2
+ import { page } from '$app/state';
3
+
4
+ import { type AnalyticsProps } from '../dist/index.js';
5
+ import { initOrWarn } from '../dist/internal.js';
6
+ let { endpoint, enabled = true }: AnalyticsProps = $props();
7
+ $effect(() => {
8
+ const tracker = initOrWarn({ endpoint, enabled, routing: 'manual' });
9
+ $effect(() => {
10
+ // FIMO-2558: Shallow back/forward updates page.url without running afterNavigate.
11
+ const url = page.url;
12
+ tracker.pageview({ path: url.pathname + url.search });
13
+ });
14
+ return () => tracker.destroy();
15
+ });
16
+ </script>
@@ -0,0 +1,5 @@
1
+ import type { Component } from 'svelte';
2
+
3
+ import type { AnalyticsProps } from '../dist/index.js';
4
+ declare const Analytics: Component<AnalyticsProps>;
5
+ export default Analytics;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ "use strict";(()=>{var g=Symbol.for("fimo.analytics.registry.v1"),m=()=>({pageview(){},destroy(){}}),P=()=>window.location.pathname+window.location.search,S=()=>new URLSearchParams(window.location.search).has("bl_preview_token");function b(t){if(typeof t!="string"||!t||t.trim()!==t||/[\\\s]/.test(t)||!(t.startsWith("/")&&!t.startsWith("//")||/^https?:\/\//i.test(t)))throw new TypeError("fimo-analytics requires an HTTP(S) endpoint or an origin-relative URL");let n=new URL(t,window.location.origin);if(!/^https?:$/.test(n.protocol)||n.username||n.password||n.hash)throw new TypeError("fimo-analytics endpoint must not contain credentials or a fragment");return n.href}var p={p:2048,n:512,r:2048,u:1024},w=(t,n)=>{if(t.length<=n)return t;let s=t.slice(0,n),a=s.charCodeAt(n-1);return a>=55296&&a<=56319?s.slice(0,-1):s},A=t=>t.length>=8&&t.length<=128;function y(t,n){let s;try{s=window[t];let e=s.getItem(n);if(e&&A(e))return e}catch{}let a=window.crypto?.randomUUID?.()??`${Date.now().toString(36)}-${Math.random().toString(36).slice(2,10)}`;try{s?.setItem(n,a)}catch{}return a}function E(t){let n=!0,s=()=>{if(n)for(let i of t.destinations.values())i.routing==="history"&&i.handles.values().next().value?.pageview()},a=["pushState","replaceState"].map(i=>{let o=window.history[i],r=function(...c){let d=o.apply(this,c);return s(),d};return window.history[i]=r,()=>{window.history[i]===r&&(window.history[i]=o)}}),e=i=>{if(i.persisted)for(let o of t.destinations.values()){let r=o.handles.values().next().value;!r||!o.lastPage||(o.lastPath=void 0,r.pageview(o.routing==="manual"?o.lastPage:void 0))}};return window.addEventListener("popstate",s),window.addEventListener("pageshow",e),()=>{n=!1,a.forEach(i=>i()),window.removeEventListener("popstate",s),window.removeEventListener("pageshow",e)}}function v(t){if(typeof window>"u"||typeof document>"u"||t.enabled===!1)return m();let n=b(t.endpoint),s=t.routing??"history";if(s!=="history"&&s!=="manual")throw new TypeError("Invalid analytics routing mode");let a=window,e=a[g]??(a[g]={destinations:new Map}),i=e.destinations.get(n);if(i?.handles.size&&i.routing!==s)throw new TypeError("Conflicting active analytics routing modes for the same endpoint");i||(i={routing:s,handles:new Set},e.destinations.set(n,i)),i.routing=s;let o=i,r=!1,c={pageview(d={}){if(r||S())return;let l=w((d.path??P()).split("#")[0]||"/",p.p);if(o.lastPath===l)return;let h=d.pageName??(l.split("?")[0].replace(/\/+$/,"")||"/");o.lastPath=l,o.lastPage={path:l,pageName:h},e.visitor??(e.visitor=y("localStorage","fimo.analytics.visitor-id")),e.session??(e.session=y("sessionStorage","fimo.analytics.session-id"));let u=new URLSearchParams({p:l,n:w(h,p.n),v:e.visitor,s:e.session});document.referrer&&u.set("r",w(document.referrer,p.r)),window.navigator.userAgent&&u.set("u",w(window.navigator.userAgent,p.u));try{window.fetch(n,{method:"POST",body:u,keepalive:!0,mode:"cors",credentials:"omit"}).catch(()=>{})}catch{}},destroy(){r||(r=!0,o.handles.delete(c),[...e.destinations.values()].some(d=>d.handles.size)||(e.stop?.(),e.stop=void 0))}};return o.handles.add(c),e.stop??(e.stop=E(e)),s==="history"&&c.pageview(),c}var f=document.currentScript;if(f)try{v({endpoint:f.dataset.endpoint,enabled:f.dataset.enabled!=="false"})}catch(t){console.warn("[fimo-analytics]",t)}})();
@@ -0,0 +1,17 @@
1
+ export type AnalyticsOptions = {
2
+ /** Complete tenant ingestion URL, including /e/p, or an explicit origin-relative proxy. */
3
+ endpoint: string;
4
+ enabled?: boolean;
5
+ routing?: 'history' | 'manual';
6
+ };
7
+ export type AnalyticsProps = Pick<AnalyticsOptions, 'endpoint' | 'enabled'>;
8
+ export type Pageview = {
9
+ path?: string;
10
+ pageName?: string;
11
+ };
12
+ export type Analytics = {
13
+ pageview(page?: Pageview): void;
14
+ destroy(): void;
15
+ };
16
+ /** Initialize once at the application root; destroy the handle when its owner unmounts. */
17
+ export declare function init(options: AnalyticsOptions): Analytics;
package/dist/index.js ADDED
@@ -0,0 +1,180 @@
1
+ // FIMO-2558: Share identity and consecutive-path dedupe across installed copies and HMR.
2
+ const registryKey = Symbol.for('fimo.analytics.registry.v1');
3
+ const inert = () => ({ pageview() { }, destroy() { } });
4
+ const browserPath = () => window.location.pathname + window.location.search;
5
+ const preview = () => new URLSearchParams(window.location.search).has('bl_preview_token');
6
+ function endpointUrl(endpoint) {
7
+ if (typeof endpoint !== 'string' ||
8
+ !endpoint ||
9
+ endpoint.trim() !== endpoint ||
10
+ /[\\\s]/.test(endpoint) ||
11
+ !((endpoint.startsWith('/') && !endpoint.startsWith('//')) || /^https?:\/\//i.test(endpoint))) {
12
+ throw new TypeError('fimo-analytics requires an HTTP(S) endpoint or an origin-relative URL');
13
+ }
14
+ const url = new URL(endpoint, window.location.origin);
15
+ if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.hash) {
16
+ throw new TypeError('fimo-analytics endpoint must not contain credentials or a fragment');
17
+ }
18
+ return url.href;
19
+ }
20
+ /**
21
+ * Ingestion validates the whole payload and rejects it outright, so a single
22
+ * over-long field drops the pageview rather than trimming it. A long referrer
23
+ * is the realistic case: some campaign and redirector URLs exceed 2048 bytes.
24
+ * Truncating records a slightly lossy visit instead of losing it entirely.
25
+ */
26
+ const CAPS = { p: 2048, n: 512, r: 2048, u: 1024 };
27
+ const clamp = (value, max) => {
28
+ if (value.length <= max)
29
+ return value;
30
+ const cut = value.slice(0, max);
31
+ // Cutting between the halves of a surrogate pair leaves a lone surrogate,
32
+ // which the form encoder replaces with U+FFFD — a stored character the
33
+ // visitor never requested. Drop the orphan instead.
34
+ const last = cut.charCodeAt(max - 1);
35
+ return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
36
+ };
37
+ /** Ingestion also bounds the identifiers; a stored value outside them would 400 every send. */
38
+ const usableId = (value) => value.length >= 8 && value.length <= 128;
39
+ function storedId(kind, key) {
40
+ let storage;
41
+ try {
42
+ storage = window[kind];
43
+ const existing = storage.getItem(key);
44
+ if (existing && usableId(existing))
45
+ return existing;
46
+ }
47
+ catch {
48
+ /* Storage may be blocked, including the property getter. */
49
+ }
50
+ const id = window.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
51
+ try {
52
+ storage?.setItem(key, id);
53
+ }
54
+ catch {
55
+ /* Keep the document's in-memory ID. */
56
+ }
57
+ return id;
58
+ }
59
+ function observe(registry) {
60
+ let active = true;
61
+ const navigate = () => {
62
+ if (!active)
63
+ return;
64
+ for (const destination of registry.destinations.values()) {
65
+ if (destination.routing === 'history')
66
+ destination.handles.values().next().value?.pageview();
67
+ }
68
+ };
69
+ const restores = ['pushState', 'replaceState'].map((method) => {
70
+ const original = window.history[method];
71
+ const wrapper = function (...args) {
72
+ const result = original.apply(this, args);
73
+ navigate();
74
+ return result;
75
+ };
76
+ window.history[method] = wrapper;
77
+ return () => {
78
+ // Do not overwrite a router or library that wrapped us after initialization.
79
+ if (window.history[method] === wrapper)
80
+ window.history[method] = original;
81
+ };
82
+ });
83
+ const restorePage = (event) => {
84
+ if (!event.persisted)
85
+ return;
86
+ for (const destination of registry.destinations.values()) {
87
+ const handle = destination.handles.values().next().value;
88
+ if (!handle || !destination.lastPage)
89
+ continue;
90
+ destination.lastPath = undefined;
91
+ handle.pageview(destination.routing === 'manual' ? destination.lastPage : undefined);
92
+ }
93
+ };
94
+ window.addEventListener('popstate', navigate);
95
+ window.addEventListener('pageshow', restorePage);
96
+ return () => {
97
+ active = false;
98
+ restores.forEach((restore) => restore());
99
+ window.removeEventListener('popstate', navigate);
100
+ window.removeEventListener('pageshow', restorePage);
101
+ };
102
+ }
103
+ /** Initialize once at the application root; destroy the handle when its owner unmounts. */
104
+ export function init(options) {
105
+ if (typeof window === 'undefined' || typeof document === 'undefined' || options.enabled === false)
106
+ return inert();
107
+ const endpoint = endpointUrl(options.endpoint);
108
+ const routing = options.routing ?? 'history';
109
+ if (routing !== 'history' && routing !== 'manual')
110
+ throw new TypeError('Invalid analytics routing mode');
111
+ const shared = window;
112
+ const registry = (shared[registryKey] ?? (shared[registryKey] = { destinations: new Map() }));
113
+ let destination = registry.destinations.get(endpoint);
114
+ if (destination?.handles.size && destination.routing !== routing) {
115
+ throw new TypeError('Conflicting active analytics routing modes for the same endpoint');
116
+ }
117
+ if (!destination) {
118
+ destination = { routing, handles: new Set() };
119
+ registry.destinations.set(endpoint, destination);
120
+ }
121
+ destination.routing = routing;
122
+ const state = destination;
123
+ let destroyed = false;
124
+ const handle = {
125
+ pageview(page = {}) {
126
+ if (destroyed || preview())
127
+ return;
128
+ // Clamp before the dedupe, not after: two navigations that differ only
129
+ // past the cap arrive as the same `p`, and comparing the raw values
130
+ // would send both and record them as duplicates of each other.
131
+ const path = clamp((page.path ?? browserPath()).split('#')[0] || '/', CAPS.p);
132
+ if (state.lastPath === path)
133
+ return;
134
+ const pageName = page.pageName ?? (path.split('?')[0].replace(/\/+$/, '') || '/');
135
+ state.lastPath = path;
136
+ state.lastPage = { path, pageName };
137
+ registry.visitor ?? (registry.visitor = storedId('localStorage', 'fimo.analytics.visitor-id'));
138
+ registry.session ?? (registry.session = storedId('sessionStorage', 'fimo.analytics.session-id'));
139
+ const body = new URLSearchParams({
140
+ p: path,
141
+ n: clamp(pageName, CAPS.n),
142
+ v: registry.visitor,
143
+ s: registry.session,
144
+ });
145
+ if (document.referrer)
146
+ body.set('r', clamp(document.referrer, CAPS.r));
147
+ if (window.navigator.userAgent)
148
+ body.set('u', clamp(window.navigator.userAgent, CAPS.u));
149
+ try {
150
+ void window
151
+ .fetch(endpoint, {
152
+ method: 'POST',
153
+ body,
154
+ keepalive: true,
155
+ mode: 'cors',
156
+ credentials: 'omit',
157
+ })
158
+ .catch(() => { });
159
+ }
160
+ catch {
161
+ /* Best effort; never retry an ambiguous delivery without server idempotency. */
162
+ }
163
+ },
164
+ destroy() {
165
+ if (destroyed)
166
+ return;
167
+ destroyed = true;
168
+ state.handles.delete(handle);
169
+ if (![...registry.destinations.values()].some((entry) => entry.handles.size)) {
170
+ registry.stop?.();
171
+ registry.stop = undefined;
172
+ }
173
+ },
174
+ };
175
+ state.handles.add(handle);
176
+ registry.stop ?? (registry.stop = observe(registry));
177
+ if (routing === 'history')
178
+ handle.pageview();
179
+ return handle;
180
+ }
@@ -0,0 +1,15 @@
1
+ import { type Analytics, type AnalyticsOptions } from './index.js';
2
+ /**
3
+ * An adapter must never take its host application down. `init` throws on a
4
+ * misconfigured endpoint — most often an unset environment variable
5
+ * interpolated into the URL, which arrives as the string `undefined/e/p` — and
6
+ * a throw inside a component's effect reaches the nearest error boundary, or
7
+ * blanks the root when there is none. Losing analytics is an acceptable
8
+ * outcome; losing the customer's page is not.
9
+ *
10
+ * `init` itself keeps throwing: it is the documented programmatic API, and a
11
+ * bad endpoint there is a programming error worth surfacing. The standalone
12
+ * browser script already degrades this way, and the framework adapters now
13
+ * match it.
14
+ */
15
+ export declare function initOrWarn(options: AnalyticsOptions): Analytics;
@@ -0,0 +1,23 @@
1
+ import { init } from './index.js';
2
+ /**
3
+ * An adapter must never take its host application down. `init` throws on a
4
+ * misconfigured endpoint — most often an unset environment variable
5
+ * interpolated into the URL, which arrives as the string `undefined/e/p` — and
6
+ * a throw inside a component's effect reaches the nearest error boundary, or
7
+ * blanks the root when there is none. Losing analytics is an acceptable
8
+ * outcome; losing the customer's page is not.
9
+ *
10
+ * `init` itself keeps throwing: it is the documented programmatic API, and a
11
+ * bad endpoint there is a programming error worth surfacing. The standalone
12
+ * browser script already degrades this way, and the framework adapters now
13
+ * match it.
14
+ */
15
+ export function initOrWarn(options) {
16
+ try {
17
+ return init(options);
18
+ }
19
+ catch (error) {
20
+ console.warn('[fimo-analytics]', error);
21
+ return { pageview() { }, destroy() { } };
22
+ }
23
+ }
package/dist/next.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { type AnalyticsProps } from './index.js';
2
+ export declare function Analytics({ endpoint, enabled }: AnalyticsProps): import("react/jsx-runtime").JSX.Element | null;
package/dist/next.js ADDED
@@ -0,0 +1,38 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useRouter } from 'next/compat/router.js';
4
+ import { usePathname, useSearchParams } from 'next/navigation.js';
5
+ import { Suspense, useEffect } from 'react';
6
+ import { initOrWarn } from './internal.js';
7
+ function AppAnalytics({ endpoint, enabled }) {
8
+ const pathname = usePathname();
9
+ const search = useSearchParams();
10
+ const query = search?.toString();
11
+ useEffect(() => {
12
+ if (pathname === null)
13
+ return;
14
+ const tracker = initOrWarn({ endpoint, enabled, routing: 'manual' });
15
+ // Read the committed browser URL to retain its original query encoding and base path.
16
+ tracker.pageview();
17
+ return () => tracker.destroy();
18
+ }, [endpoint, enabled, pathname, query]);
19
+ return null;
20
+ }
21
+ export function Analytics({ endpoint, enabled }) {
22
+ const router = useRouter();
23
+ const ready = router?.isReady;
24
+ const events = router?.events;
25
+ useEffect(() => {
26
+ if (!ready || !events)
27
+ return;
28
+ const tracker = initOrWarn({ endpoint, enabled, routing: 'manual' });
29
+ const committed = () => tracker.pageview();
30
+ committed();
31
+ events.on('routeChangeComplete', committed);
32
+ return () => {
33
+ events.off('routeChangeComplete', committed);
34
+ tracker.destroy();
35
+ };
36
+ }, [endpoint, enabled, ready, events]);
37
+ return router ? null : (_jsx(Suspense, { fallback: null, children: _jsx(AppAnalytics, { endpoint: endpoint, enabled: enabled }) }));
38
+ }
@@ -0,0 +1,23 @@
1
+ import { defineNuxtPlugin, useRuntimeConfig, useRouter } from '#app';
2
+
3
+ import { initOrWarn } from './internal.js';
4
+
5
+ export default defineNuxtPlugin((app) => {
6
+ const options = useRuntimeConfig().public.fimoAnalytics;
7
+ const router = useRouter();
8
+ let tracker;
9
+ const mounted = app.hook('app:mounted', () => {
10
+ tracker = initOrWarn({ ...options, routing: 'manual' });
11
+ tracker.pageview();
12
+ });
13
+ const remove = router.afterEach((_to, _from, failure) => {
14
+ if (!failure) tracker?.pageview();
15
+ });
16
+ const destroy = () => {
17
+ mounted();
18
+ remove();
19
+ tracker?.destroy();
20
+ };
21
+ app.vueApp.onUnmount(destroy);
22
+ if (import.meta.hot) import.meta.hot.dispose(destroy);
23
+ });
package/dist/nuxt.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { NuxtModule } from '@nuxt/schema';
2
+ import type { AnalyticsProps } from './index.js';
3
+ declare const analytics: NuxtModule<AnalyticsProps>;
4
+ export default analytics;
5
+ declare module '@nuxt/schema' {
6
+ interface NuxtConfig {
7
+ analytics?: AnalyticsProps;
8
+ }
9
+ interface NuxtOptions {
10
+ analytics?: AnalyticsProps;
11
+ }
12
+ }
package/dist/nuxt.js ADDED
@@ -0,0 +1,10 @@
1
+ import { addPlugin, createResolver, defineNuxtModule } from '@nuxt/kit';
2
+ const analytics = defineNuxtModule({
3
+ meta: { name: 'fimo-analytics', configKey: 'analytics' },
4
+ defaults: { endpoint: '', enabled: true },
5
+ setup(options, nuxt) {
6
+ nuxt.options.runtimeConfig.public.fimoAnalytics = options;
7
+ addPlugin({ src: createResolver(import.meta.url).resolve('./nuxt-plugin.mjs'), mode: 'client' });
8
+ },
9
+ });
10
+ export default analytics;
@@ -0,0 +1,4 @@
1
+ import { type AnalyticsProps } from './index.js';
2
+ export declare function Analytics({ endpoint, enabled, pageName }: AnalyticsProps & {
3
+ pageName?: string;
4
+ }): null;
@@ -0,0 +1,13 @@
1
+ 'use client';
2
+ import { useEffect } from 'react';
3
+ import { useLocation } from 'react-router';
4
+ import { initOrWarn } from './internal.js';
5
+ export function Analytics({ endpoint, enabled, pageName }) {
6
+ const { pathname, search } = useLocation();
7
+ useEffect(() => {
8
+ const tracker = initOrWarn({ endpoint, enabled, routing: 'manual' });
9
+ tracker.pageview({ path: pathname + search, pageName });
10
+ return () => tracker.destroy();
11
+ }, [endpoint, enabled, pathname, search, pageName]);
12
+ return null;
13
+ }
@@ -0,0 +1,2 @@
1
+ import { type AnalyticsProps } from './index.js';
2
+ export declare function Analytics({ endpoint, enabled }: AnalyticsProps): null;
package/dist/react.js ADDED
@@ -0,0 +1,10 @@
1
+ 'use client';
2
+ import { useEffect } from 'react';
3
+ import { initOrWarn } from './internal.js';
4
+ export function Analytics({ endpoint, enabled }) {
5
+ useEffect(() => {
6
+ const tracker = initOrWarn({ endpoint, enabled });
7
+ return () => tracker.destroy();
8
+ }, [endpoint, enabled]);
9
+ return null;
10
+ }
package/dist/vue.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ export declare const Analytics: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
2
+ endpoint: {
3
+ type: StringConstructor;
4
+ required: true;
5
+ };
6
+ enabled: {
7
+ type: BooleanConstructor;
8
+ default: boolean;
9
+ };
10
+ }>, () => null, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
11
+ endpoint: {
12
+ type: StringConstructor;
13
+ required: true;
14
+ };
15
+ enabled: {
16
+ type: BooleanConstructor;
17
+ default: boolean;
18
+ };
19
+ }>> & Readonly<{}>, {
20
+ enabled: boolean;
21
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
package/dist/vue.js ADDED
@@ -0,0 +1,21 @@
1
+ import { defineComponent, onMounted, onUnmounted, watch } from 'vue';
2
+ import { initOrWarn } from './internal.js';
3
+ export const Analytics = defineComponent({
4
+ name: 'FimoAnalytics',
5
+ props: { endpoint: { type: String, required: true }, enabled: { type: Boolean, default: true } },
6
+ setup(props) {
7
+ let tracker;
8
+ let stop;
9
+ onMounted(() => {
10
+ stop = watch(() => [props.endpoint, props.enabled], () => {
11
+ tracker?.destroy();
12
+ tracker = initOrWarn({ endpoint: props.endpoint, enabled: props.enabled });
13
+ }, { immediate: true });
14
+ });
15
+ onUnmounted(() => {
16
+ stop?.();
17
+ tracker?.destroy();
18
+ });
19
+ return () => null;
20
+ },
21
+ });
package/package.json ADDED
@@ -0,0 +1,120 @@
1
+ {
2
+ "name": "fimo-analytics",
3
+ "version": "2.7.0-experimental.1",
4
+ "description": "Framework-independent Fimo pageview analytics with optional framework adapters.",
5
+ "files": [
6
+ "dist/",
7
+ "components/",
8
+ "README.md"
9
+ ],
10
+ "type": "module",
11
+ "sideEffects": [
12
+ "./dist/browser.js",
13
+ "./components/*.astro",
14
+ "./components/*.svelte"
15
+ ],
16
+ "exports": {
17
+ "./package.json": "./package.json",
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ },
22
+ "./react": {
23
+ "types": "./dist/react.d.ts",
24
+ "import": "./dist/react.js"
25
+ },
26
+ "./react-router": {
27
+ "types": "./dist/react-router.d.ts",
28
+ "import": "./dist/react-router.js"
29
+ },
30
+ "./next": {
31
+ "types": "./dist/next.d.ts",
32
+ "import": "./dist/next.js"
33
+ },
34
+ "./vue": {
35
+ "types": "./dist/vue.d.ts",
36
+ "import": "./dist/vue.js"
37
+ },
38
+ "./nuxt": {
39
+ "types": "./dist/nuxt.d.ts",
40
+ "import": "./dist/nuxt.js"
41
+ },
42
+ "./svelte": {
43
+ "types": "./components/svelte.d.ts",
44
+ "svelte": "./components/Analytics.svelte",
45
+ "default": "./components/Analytics.svelte"
46
+ },
47
+ "./sveltekit": {
48
+ "types": "./components/svelte.d.ts",
49
+ "svelte": "./components/SvelteKit.svelte",
50
+ "default": "./components/SvelteKit.svelte"
51
+ },
52
+ "./astro": "./components/Analytics.astro",
53
+ "./browser": "./dist/browser.js"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ },
58
+ "scripts": {
59
+ "build": "tsc -p tsconfig.json && node build.mjs",
60
+ "check:types": "tsc -p tsconfig.json --noEmit",
61
+ "test": "pnpm build && node --test test/*.test.mjs",
62
+ "test:e2e": "node test/run-e2e.mjs",
63
+ "test:fixtures": "node test/fixtures.mjs",
64
+ "clean": "rm -rf dist"
65
+ },
66
+ "devDependencies": {
67
+ "@nuxt/kit": "4.5.2",
68
+ "@nuxt/schema": "4.5.2",
69
+ "@types/react": "19.2.14",
70
+ "esbuild": "^0.25.10",
71
+ "next": "16.3.3",
72
+ "react": "19.2.4",
73
+ "react-router": "7.18.2",
74
+ "typescript": "7.0.2",
75
+ "vue": "3.5.42"
76
+ },
77
+ "peerDependencies": {
78
+ "@nuxt/kit": "^4.5.2",
79
+ "@sveltejs/kit": "^2.12.0",
80
+ "astro": "^7.0.0",
81
+ "next": "^15.0.0 || ^16.0.0",
82
+ "nuxt": "^4.5.2",
83
+ "react": "^18.2.0 || ^19.0.0",
84
+ "react-router": "^7.0.0 || ^8.0.0",
85
+ "svelte": "^5.0.0",
86
+ "vue": "^3.5.0"
87
+ },
88
+ "peerDependenciesMeta": {
89
+ "@nuxt/kit": {
90
+ "optional": true
91
+ },
92
+ "nuxt": {
93
+ "optional": true
94
+ },
95
+ "react": {
96
+ "optional": true
97
+ },
98
+ "react-router": {
99
+ "optional": true
100
+ },
101
+ "next": {
102
+ "optional": true
103
+ },
104
+ "astro": {
105
+ "optional": true
106
+ },
107
+ "svelte": {
108
+ "optional": true
109
+ },
110
+ "@sveltejs/kit": {
111
+ "optional": true
112
+ },
113
+ "vue": {
114
+ "optional": true
115
+ }
116
+ },
117
+ "engines": {
118
+ "node": ">=20.12.0"
119
+ }
120
+ }