@faststats/web 0.0.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.
@@ -0,0 +1,207 @@
1
+ import {
2
+ type MetricWithAttribution,
3
+ onCLS,
4
+ onFCP,
5
+ onINP,
6
+ onLCP,
7
+ onTTFB,
8
+ } from "web-vitals/attribution";
9
+ import { normalizeSamplingPercentage } from "./utils/types";
10
+
11
+ export interface WebVitalsOptions {
12
+ siteKey: string;
13
+ endpoint?: string;
14
+ debug?: boolean;
15
+ samplingPercentage?: number;
16
+ }
17
+
18
+ type MetricName = "CLS" | "INP" | "LCP" | "FCP" | "TTFB";
19
+
20
+ type MetricState = {
21
+ value: number;
22
+ attributes: Record<string, unknown>;
23
+ final: boolean;
24
+ };
25
+
26
+ class WebVitalsTracker {
27
+ private readonly endpoint: string;
28
+ private readonly siteKey: string;
29
+ private readonly debug: boolean;
30
+ private readonly samplingPercentage: number;
31
+
32
+ private started = false;
33
+ private readonly sessionSamplingSeed: number;
34
+ private readonly metricsMap = new Map<MetricName, MetricState>();
35
+ private flushed = false;
36
+
37
+ constructor(options: WebVitalsOptions) {
38
+ this.siteKey = options.siteKey;
39
+ this.endpoint = this.getVitalsEndpoint(
40
+ options.endpoint ?? "https://metrics.faststats.dev/v1/web",
41
+ );
42
+ this.debug = options.debug ?? false;
43
+ this.samplingPercentage = normalizeSamplingPercentage(
44
+ options.samplingPercentage,
45
+ );
46
+ this.sessionSamplingSeed = Math.random() * 100;
47
+ }
48
+
49
+ private getVitalsEndpoint(baseEndpoint: string): string {
50
+ const url = new URL(baseEndpoint);
51
+ const pathParts = url.pathname.split("/");
52
+ pathParts[pathParts.length - 1] = "vitals";
53
+ url.pathname = pathParts.join("/");
54
+ return url.toString();
55
+ }
56
+
57
+ start(): void {
58
+ if (this.started || typeof window === "undefined") return;
59
+
60
+ if (window.__FA_isTrackingDisabled?.()) {
61
+ if (this.debug) {
62
+ console.log("[WebVitals] Tracking disabled");
63
+ }
64
+ return;
65
+ }
66
+
67
+ this.started = true;
68
+
69
+ document.addEventListener("visibilitychange", () => {
70
+ if (document.visibilityState === "hidden") {
71
+ this.finalizeAndFlush();
72
+ }
73
+ });
74
+ window.addEventListener("pagehide", () => {
75
+ this.finalizeAndFlush();
76
+ });
77
+
78
+ if (this.debug) {
79
+ console.log("[WebVitals] Tracking started");
80
+ }
81
+
82
+ onCLS((metric) => this.captureMetric(metric));
83
+ onINP((metric) => this.captureMetric(metric));
84
+ onLCP((metric) => this.captureMetric(metric));
85
+ onFCP((metric) => this.captureMetric(metric));
86
+ onTTFB((metric) => this.captureMetric(metric));
87
+ }
88
+
89
+ private captureMetric(metric: MetricWithAttribution): void {
90
+ if (this.flushed) return;
91
+
92
+ if (
93
+ this.samplingPercentage < 100 &&
94
+ this.sessionSamplingSeed >= this.samplingPercentage
95
+ ) {
96
+ return;
97
+ }
98
+
99
+ const metricName = metric.name as MetricName;
100
+
101
+ const attributes: Record<string, unknown> = {
102
+ id: metric.id,
103
+ rating: metric.rating,
104
+ delta: metric.delta,
105
+ navigationType: metric.navigationType,
106
+ ...(metric.attribution ?? {}),
107
+ };
108
+
109
+ const isFinal = metricName === "FCP" || metricName === "TTFB";
110
+
111
+ this.metricsMap.set(metricName, {
112
+ value: metric.value,
113
+ attributes,
114
+ final: isFinal,
115
+ });
116
+
117
+ if (this.debug) {
118
+ console.log(
119
+ `[WebVitals] ${metricName} captured: ${metric.value}` +
120
+ (isFinal ? " (final)" : ""),
121
+ );
122
+ }
123
+ }
124
+
125
+ private finalizeAndFlush(): void {
126
+ if (this.flushed || this.metricsMap.size === 0) {
127
+ return;
128
+ }
129
+
130
+ for (const [name, metric] of this.metricsMap.entries()) {
131
+ if (!metric.final) {
132
+ metric.final = true;
133
+
134
+ if (this.debug) {
135
+ console.log(`[WebVitals] ${name} finalized: ${metric.value}`);
136
+ }
137
+ }
138
+ }
139
+
140
+ this.flushWithBeacon();
141
+ }
142
+
143
+ private buildPayload(): { body: string; count: number } | null {
144
+ if (this.metricsMap.size === 0) return null;
145
+
146
+ const vitals = Array.from(this.metricsMap.entries()).map(
147
+ ([metric, data]) => ({
148
+ metric,
149
+ value: data.value,
150
+ attributes: data.attributes,
151
+ }),
152
+ );
153
+
154
+ return {
155
+ body: JSON.stringify({
156
+ sessionId: window.__FA_getSessionId?.(),
157
+ vitals,
158
+ metadata: {
159
+ url: window.location.href,
160
+ },
161
+ }),
162
+ count: vitals.length,
163
+ };
164
+ }
165
+
166
+ private flushWithBeacon(): void {
167
+ const payload = this.buildPayload();
168
+ if (!payload) return;
169
+
170
+ this.flushed = true;
171
+
172
+ if (this.debug) {
173
+ const names = Array.from(this.metricsMap.keys()).join(", ");
174
+ console.log(
175
+ `[WebVitals] Sending final metrics (${payload.count}): ${names}`,
176
+ );
177
+ }
178
+
179
+ fetch(this.endpoint, {
180
+ method: "POST",
181
+ body: payload.body,
182
+ headers: {
183
+ "Content-Type": "application/json",
184
+ Authorization: `Bearer ${this.siteKey}`,
185
+ },
186
+ keepalive: true,
187
+ }).catch(() => {
188
+ if (this.debug) {
189
+ console.warn("[WebVitals] Failed to send metrics");
190
+ }
191
+ });
192
+ }
193
+ }
194
+
195
+ export default WebVitalsTracker;
196
+
197
+ declare global {
198
+ interface Window {
199
+ __FA_WebVitalsTracker?: typeof WebVitalsTracker;
200
+ __FA_getSessionId?: () => string;
201
+ __FA_isTrackingDisabled?: () => boolean;
202
+ }
203
+ }
204
+
205
+ if (typeof window !== "undefined") {
206
+ window.__FA_WebVitalsTracker = WebVitalsTracker;
207
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext", "DOM"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ },
29
+ "include": ["src"]
30
+ }
@@ -0,0 +1,51 @@
1
+ import { defineConfig, type UserConfig } from "tsdown";
2
+
3
+ const iifeBase: UserConfig = {
4
+ format: ["iife"] as ["iife"],
5
+ platform: "browser",
6
+ outDir: "dist",
7
+ minify: true,
8
+ noExternal: [
9
+ "web-vitals",
10
+ "web-vitals/attribution",
11
+ "rrweb",
12
+ "@rrweb/rrweb-plugin-console-record",
13
+ "@rrweb/types",
14
+ "rrweb-snapshot",
15
+ ],
16
+ };
17
+
18
+ export default defineConfig([
19
+ {
20
+ ...iifeBase,
21
+ entry: { script: "src/index.ts" },
22
+ globalName: "FA_Script",
23
+ clean: true,
24
+ },
25
+ {
26
+ ...iifeBase,
27
+ entry: { error: "src/error.ts" },
28
+ globalName: "FA_Error",
29
+ },
30
+ {
31
+ ...iifeBase,
32
+ entry: { "web-vitals": "src/web-vitals.ts" },
33
+ globalName: "FA_WebVitals",
34
+ inlineOnly: ["web-vitals"],
35
+ },
36
+ {
37
+ ...iifeBase,
38
+ entry: { replay: "src/replay.ts" },
39
+ globalName: "FA_Replay",
40
+ inlineOnly: ["rrweb", "@rrweb/rrweb-plugin-console-record"],
41
+ },
42
+ {
43
+ entry: "src/module.ts",
44
+ format: ["esm"],
45
+ platform: "browser",
46
+ outDir: "dist",
47
+ dts: true,
48
+ inlineOnly: false,
49
+ checks: { pluginTimings: false },
50
+ },
51
+ ]);
@@ -0,0 +1,22 @@
1
+ export default {
2
+ async fetch(
3
+ request: Request,
4
+ env: { ASSETS: { fetch: typeof fetch } },
5
+ ): Promise<Response> {
6
+ const response = await env.ASSETS.fetch(request);
7
+
8
+ if (response.ok) {
9
+ const newHeaders = new Headers(response.headers);
10
+ newHeaders.set("Cache-Control", "public, max-age=31536000, immutable");
11
+ newHeaders.set("Access-Control-Allow-Origin", "*");
12
+
13
+ return new Response(response.body, {
14
+ status: response.status,
15
+ statusText: response.statusText,
16
+ headers: newHeaders,
17
+ });
18
+ }
19
+
20
+ return response;
21
+ },
22
+ };
package/wrangler.toml ADDED
@@ -0,0 +1,8 @@
1
+ name = "web-analytics"
2
+ main = "worker/index.ts"
3
+ compatibility_date = "2024-01-01"
4
+
5
+ [assets]
6
+ directory = "./dist"
7
+ binding = "ASSETS"
8
+ run_worker_first = true