alabjs 0.1.1 → 0.2.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.
Files changed (75) hide show
  1. package/dist/analytics/handler.d.ts +24 -0
  2. package/dist/analytics/handler.d.ts.map +1 -0
  3. package/dist/analytics/handler.js +87 -0
  4. package/dist/analytics/handler.js.map +1 -0
  5. package/dist/analytics/store.d.ts +33 -0
  6. package/dist/analytics/store.d.ts.map +1 -0
  7. package/dist/analytics/store.js +68 -0
  8. package/dist/analytics/store.js.map +1 -0
  9. package/dist/analytics/store.test.d.ts +2 -0
  10. package/dist/analytics/store.test.d.ts.map +1 -0
  11. package/dist/analytics/store.test.js +42 -0
  12. package/dist/analytics/store.test.js.map +1 -0
  13. package/dist/commands/build.d.ts.map +1 -1
  14. package/dist/commands/build.js +104 -2
  15. package/dist/commands/build.js.map +1 -1
  16. package/dist/commands/dev.d.ts.map +1 -1
  17. package/dist/commands/dev.js +6 -0
  18. package/dist/commands/dev.js.map +1 -1
  19. package/dist/components/Analytics.d.ts +48 -0
  20. package/dist/components/Analytics.d.ts.map +1 -0
  21. package/dist/components/Analytics.js +154 -0
  22. package/dist/components/Analytics.js.map +1 -0
  23. package/dist/components/Dynamic.d.ts +88 -0
  24. package/dist/components/Dynamic.d.ts.map +1 -0
  25. package/dist/components/Dynamic.js +86 -0
  26. package/dist/components/Dynamic.js.map +1 -0
  27. package/dist/components/index.d.ts +4 -0
  28. package/dist/components/index.d.ts.map +1 -1
  29. package/dist/components/index.js +2 -0
  30. package/dist/components/index.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/server/app.d.ts.map +1 -1
  35. package/dist/server/app.js +72 -8
  36. package/dist/server/app.js.map +1 -1
  37. package/dist/server/cdn.d.ts +72 -0
  38. package/dist/server/cdn.d.ts.map +1 -0
  39. package/dist/server/cdn.js +132 -0
  40. package/dist/server/cdn.js.map +1 -0
  41. package/dist/server/revalidate.d.ts.map +1 -1
  42. package/dist/server/revalidate.js +6 -1
  43. package/dist/server/revalidate.js.map +1 -1
  44. package/dist/ssr/html.d.ts +7 -0
  45. package/dist/ssr/html.d.ts.map +1 -1
  46. package/dist/ssr/html.js +2 -1
  47. package/dist/ssr/html.js.map +1 -1
  48. package/dist/ssr/ppr.d.ts +69 -0
  49. package/dist/ssr/ppr.d.ts.map +1 -0
  50. package/dist/ssr/ppr.js +132 -0
  51. package/dist/ssr/ppr.js.map +1 -0
  52. package/dist/ssr/render.d.ts +2 -0
  53. package/dist/ssr/render.d.ts.map +1 -1
  54. package/dist/ssr/render.js +2 -1
  55. package/dist/ssr/render.js.map +1 -1
  56. package/dist/types/index.d.ts +20 -1
  57. package/dist/types/index.d.ts.map +1 -1
  58. package/package.json +5 -1
  59. package/src/analytics/handler.ts +110 -0
  60. package/src/analytics/store.test.ts +45 -0
  61. package/src/analytics/store.ts +94 -0
  62. package/src/commands/build.ts +117 -2
  63. package/src/commands/dev.ts +7 -0
  64. package/src/components/Analytics.tsx +164 -0
  65. package/src/components/Dynamic.tsx +124 -0
  66. package/src/components/index.ts +4 -0
  67. package/src/index.ts +1 -0
  68. package/src/server/app.ts +82 -9
  69. package/src/server/cdn.ts +187 -0
  70. package/src/server/revalidate.ts +7 -1
  71. package/src/ssr/html.ts +9 -0
  72. package/src/ssr/ppr.ts +167 -0
  73. package/src/ssr/render.ts +4 -0
  74. package/src/types/index.ts +23 -0
  75. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alabjs",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "AlabJS — full-stack React framework powered by a Rust compiler",
5
5
  "keywords": [
6
6
  "react",
@@ -61,6 +61,10 @@
61
61
  "import": "./dist/server/cache.js",
62
62
  "types": "./dist/server/cache.d.ts"
63
63
  },
64
+ "./analytics": {
65
+ "import": "./dist/analytics/store.js",
66
+ "types": "./dist/analytics/store.d.ts"
67
+ },
64
68
  "./adapters/cloudflare": {
65
69
  "import": "./dist/adapters/cloudflare.js",
66
70
  "types": "./dist/adapters/cloudflare.d.ts"
@@ -0,0 +1,110 @@
1
+ /**
2
+ * H3 route handlers for the AlabJS analytics endpoints.
3
+ *
4
+ * POST /_alabjs/vitals — receives Core Web Vitals beacons from the browser.
5
+ * GET /_alabjs/analytics — returns aggregated per-route stats (requires auth).
6
+ */
7
+
8
+ import type { IncomingMessage, ServerResponse } from "node:http";
9
+ import { recordMetric, getSnapshot, type MetricName } from "./store.js";
10
+
11
+ const METRIC_NAMES = new Set<string>(["LCP", "CLS", "INP", "TTFB", "FCP"]);
12
+
13
+ /** Read the raw request body as a UTF-8 string. */
14
+ function readBody(req: IncomingMessage): Promise<string> {
15
+ return new Promise((resolve, reject) => {
16
+ const chunks: Buffer[] = [];
17
+ req.on("data", (c: Buffer) => chunks.push(c));
18
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
19
+ req.on("error", reject);
20
+ });
21
+ }
22
+
23
+ /**
24
+ * POST /_alabjs/vitals
25
+ *
26
+ * Accepts a JSON beacon: { name, value, route }
27
+ * Called by the browser via navigator.sendBeacon — no auth required.
28
+ * Always responds 204 so the browser doesn't wait.
29
+ */
30
+ export async function handleVitalsBeacon(
31
+ req: IncomingMessage,
32
+ res: ServerResponse,
33
+ ): Promise<void> {
34
+ res.setHeader("access-control-allow-origin", "*");
35
+
36
+ if (req.method === "OPTIONS") {
37
+ res.setHeader("access-control-allow-methods", "POST, OPTIONS");
38
+ res.setHeader("access-control-allow-headers", "content-type");
39
+ res.statusCode = 204;
40
+ res.end();
41
+ return;
42
+ }
43
+
44
+ if (req.method !== "POST") {
45
+ res.statusCode = 405;
46
+ res.end();
47
+ return;
48
+ }
49
+
50
+ try {
51
+ const raw = await readBody(req);
52
+ // sendBeacon may batch multiple events as a JSON array or single object.
53
+ const parsed: unknown = JSON.parse(raw);
54
+ const events = Array.isArray(parsed) ? parsed : [parsed];
55
+
56
+ for (const ev of events) {
57
+ if (
58
+ ev !== null &&
59
+ typeof ev === "object" &&
60
+ "name" in ev && typeof ev.name === "string" &&
61
+ "value" in ev && typeof ev.value === "number" &&
62
+ "route" in ev && typeof ev.route === "string" &&
63
+ METRIC_NAMES.has(ev.name)
64
+ ) {
65
+ recordMetric(
66
+ ev.route.slice(0, 256), // cap length for safety
67
+ ev.name as MetricName,
68
+ ev.value,
69
+ );
70
+ }
71
+ }
72
+ } catch {
73
+ // Malformed beacon — swallow silently, still return 204.
74
+ }
75
+
76
+ res.statusCode = 204;
77
+ res.end();
78
+ }
79
+
80
+ /**
81
+ * GET /_alabjs/analytics
82
+ *
83
+ * Returns a JSON snapshot of all collected metrics.
84
+ * Protected by Authorization: Bearer <ALAB_ANALYTICS_SECRET>.
85
+ * Falls back to ALAB_REVALIDATE_SECRET if ALAB_ANALYTICS_SECRET is unset.
86
+ */
87
+ export function handleAnalyticsDashboard(
88
+ req: IncomingMessage,
89
+ res: ServerResponse,
90
+ ): void {
91
+ const secret =
92
+ process.env["ALAB_ANALYTICS_SECRET"] ??
93
+ process.env["ALAB_REVALIDATE_SECRET"];
94
+
95
+ if (secret) {
96
+ const auth = req.headers["authorization"] ?? "";
97
+ const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
98
+ if (token !== secret) {
99
+ res.statusCode = 401;
100
+ res.setHeader("content-type", "application/json");
101
+ res.end(JSON.stringify({ error: "Unauthorized. Set Authorization: Bearer <ALAB_ANALYTICS_SECRET>." }));
102
+ return;
103
+ }
104
+ }
105
+
106
+ res.statusCode = 200;
107
+ res.setHeader("content-type", "application/json; charset=utf-8");
108
+ res.setHeader("cache-control", "no-store");
109
+ res.end(JSON.stringify(getSnapshot(), null, 2));
110
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { recordMetric, getSnapshot, clearStore } from "./store.js";
3
+
4
+ beforeEach(() => clearStore());
5
+
6
+ describe("recordMetric / getSnapshot", () => {
7
+ it("records a single LCP and counts it as a pageview", () => {
8
+ recordMetric("/blog", "LCP", 1200);
9
+ const snap = getSnapshot();
10
+ expect(snap.routes["/blog"]?.pageviews).toBe(1);
11
+ expect(snap.routes["/blog"]?.lcp_p75).toBe(1200);
12
+ });
13
+
14
+ it("computes p75 correctly across multiple samples", () => {
15
+ // 4 samples: [100, 200, 300, 400] → sorted → index floor(4*0.75)=3 → 400
16
+ for (const v of [300, 100, 400, 200]) recordMetric("/", "LCP", v);
17
+ const snap = getSnapshot();
18
+ expect(snap.routes["/"]?.lcp_p75).toBe(400);
19
+ });
20
+
21
+ it("evicts oldest sample when ring buffer is full", () => {
22
+ // Fill 500 samples with value 1, then add one with value 9999
23
+ for (let i = 0; i < 500; i++) recordMetric("/test", "FCP", 1);
24
+ recordMetric("/test", "FCP", 9999);
25
+ const snap = getSnapshot();
26
+ // Ring should still have exactly 500 entries (oldest 1 evicted, 9999 added)
27
+ // p75 of 499×1 + 1×9999 = index 374 → 1
28
+ expect(snap.routes["/test"]?.fcp_p75).toBe(1);
29
+ });
30
+
31
+ it("ignores unknown metric names", () => {
32
+ // @ts-expect-error intentionally invalid
33
+ recordMetric("/x", "UNKNOWN", 100);
34
+ const snap = getSnapshot();
35
+ expect(snap.routes["/x"]).toBeUndefined();
36
+ });
37
+
38
+ it("tracks multiple routes independently", () => {
39
+ recordMetric("/a", "CLS", 0.05);
40
+ recordMetric("/b", "CLS", 0.2);
41
+ const snap = getSnapshot();
42
+ expect(snap.routes["/a"]?.cls_p75).toBe(0.05);
43
+ expect(snap.routes["/b"]?.cls_p75).toBe(0.2);
44
+ });
45
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * In-memory analytics store.
3
+ *
4
+ * Keeps a ring buffer of the last RING_SIZE samples for each
5
+ * (route, metric) pair and exposes p75 aggregates.
6
+ *
7
+ * All writes are synchronous and happen in the same Node.js event-loop
8
+ * turn as the beacon POST — no blocking, no external I/O.
9
+ */
10
+
11
+ /** Maximum samples retained per (route, metric) bucket. */
12
+ const RING_SIZE = 500;
13
+
14
+ export type MetricName = "LCP" | "CLS" | "INP" | "TTFB" | "FCP";
15
+
16
+ const METRIC_NAMES: MetricName[] = ["LCP", "CLS", "INP", "TTFB", "FCP"];
17
+
18
+ interface RouteBucket {
19
+ /** Ring buffers — one per metric. */
20
+ samples: Record<MetricName, number[]>;
21
+ /** Number of LCP events received (proxy for page-view count). */
22
+ pageviews: number;
23
+ }
24
+
25
+ /** route path → bucket */
26
+ const store = new Map<string, RouteBucket>();
27
+
28
+ function getBucket(route: string): RouteBucket {
29
+ let bucket = store.get(route);
30
+ if (!bucket) {
31
+ const samples = {} as Record<MetricName, number[]>;
32
+ for (const m of METRIC_NAMES) samples[m] = [];
33
+ bucket = { samples, pageviews: 0 };
34
+ store.set(route, bucket);
35
+ }
36
+ return bucket;
37
+ }
38
+
39
+ /**
40
+ * Record one metric sample for a route.
41
+ * If the ring buffer is full, the oldest sample is evicted.
42
+ */
43
+ export function recordMetric(route: string, name: MetricName, value: number): void {
44
+ if (!METRIC_NAMES.includes(name)) return;
45
+ const bucket = getBucket(route);
46
+ const buf = bucket.samples[name];
47
+ buf.push(value);
48
+ if (buf.length > RING_SIZE) buf.shift();
49
+ if (name === "LCP") bucket.pageviews++;
50
+ }
51
+
52
+ /** Compute the p75 of an array of numbers, or null if empty. */
53
+ function p75(values: number[]): number | null {
54
+ if (values.length === 0) return null;
55
+ const sorted = [...values].sort((a, b) => a - b);
56
+ const idx = Math.floor(sorted.length * 0.75);
57
+ return Math.round((sorted[idx] ?? sorted[sorted.length - 1]!) * 100) / 100;
58
+ }
59
+
60
+ export interface RouteStats {
61
+ pageviews: number;
62
+ lcp_p75: number | null;
63
+ cls_p75: number | null;
64
+ inp_p75: number | null;
65
+ ttfb_p75: number | null;
66
+ fcp_p75: number | null;
67
+ }
68
+
69
+ export interface AnalyticsSnapshot {
70
+ routes: Record<string, RouteStats>;
71
+ /** ISO timestamp of when the snapshot was taken. */
72
+ asOf: string;
73
+ }
74
+
75
+ /** Return an aggregated snapshot of all collected metrics. */
76
+ export function getSnapshot(): AnalyticsSnapshot {
77
+ const routes: Record<string, RouteStats> = {};
78
+ for (const [route, bucket] of store.entries()) {
79
+ routes[route] = {
80
+ pageviews: bucket.pageviews,
81
+ lcp_p75: p75(bucket.samples.LCP),
82
+ cls_p75: p75(bucket.samples.CLS),
83
+ inp_p75: p75(bucket.samples.INP),
84
+ ttfb_p75: p75(bucket.samples.TTFB),
85
+ fcp_p75: p75(bucket.samples.FCP),
86
+ };
87
+ }
88
+ return { routes, asOf: new Date().toISOString() };
89
+ }
90
+
91
+ /** Clear all stored data (useful in tests). */
92
+ export function clearStore(): void {
93
+ store.clear();
94
+ }
@@ -1,7 +1,9 @@
1
1
  import { build as viteBuild, type PluginOption } from "vite";
2
2
  import { resolve } from "node:path";
3
- import { spawn } from "node:child_process";
4
- import { existsSync, writeFileSync } from "node:fs";
3
+ import { spawn, execSync } from "node:child_process";
4
+ import { existsSync, writeFileSync, readFileSync } from "node:fs";
5
+ import { preRenderPPRShell, findBuildLayoutFiles, PPR_CACHE_SUBDIR } from "../ssr/ppr.js";
6
+ import type { RouteManifest } from "../router/manifest.js";
5
7
 
6
8
  interface BuildOptions {
7
9
  cwd: string;
@@ -165,6 +167,12 @@ export async function build({ cwd, skipTypecheck = false, mode = "ssr", analyze
165
167
 
166
168
  await Promise.all(tasks);
167
169
 
170
+ // Write a stable build ID for skew protection (must run after Vite so the
171
+ // route-manifest.json is in place for the content-hash fallback path).
172
+ const distDir = resolve(cwd, ".alabjs/dist");
173
+ await writeBuildId(distDir, cwd);
174
+ await buildPPRShells(distDir, cwd);
175
+
168
176
  // Bundle the offline service worker as a separate iife chunk.
169
177
  // Output: .alabjs/dist/client/_alabjs/offline-sw.js (served at /_alabjs/offline-sw.js)
170
178
  await buildOfflineSw(cwd);
@@ -172,6 +180,113 @@ export async function build({ cwd, skipTypecheck = false, mode = "ssr", analyze
172
180
  console.log("\n alab build complete → .alabjs/dist");
173
181
  }
174
182
 
183
+ /**
184
+ * Generate a stable build ID and write it to `.alabjs/dist/BUILD_ID`.
185
+ *
186
+ * Strategy (in priority order):
187
+ * 1. Git short SHA — deterministic, human-readable, zero CPU cost.
188
+ * 2. Rust FNV-1a hash of the route-manifest JSON via `@alabjs/compiler`
189
+ * (napi binary) — content-addressed, no git required.
190
+ * 3. Base-36 millisecond timestamp — last resort when both git and napi
191
+ * are unavailable (e.g. first-time contributor without Rust toolchain).
192
+ */
193
+ async function writeBuildId(distDir: string, cwd: string): Promise<void> {
194
+ let buildId: string;
195
+
196
+ // 1. Git SHA (preferred — zero cost, guaranteed unique per commit)
197
+ try {
198
+ buildId = execSync("git rev-parse --short HEAD", { cwd, encoding: "utf8" }).trim();
199
+ } catch {
200
+ // 2. Rust FNV-1a hash of the route manifest (content-addressed)
201
+ try {
202
+ const manifestPath = resolve(distDir, "route-manifest.json");
203
+ const manifestContent = readFileSync(manifestPath, "utf8");
204
+ type NapiWithHash = { hashBuildId(s: string): string };
205
+ const mod = await import("@alabjs/compiler") as unknown as { default?: NapiWithHash } & NapiWithHash;
206
+ const napi: NapiWithHash = (mod.default ?? mod) as NapiWithHash;
207
+ if (typeof napi.hashBuildId === "function") {
208
+ buildId = napi.hashBuildId(manifestContent);
209
+ } else {
210
+ throw new Error("hashBuildId not available");
211
+ }
212
+ } catch {
213
+ // 3. Timestamp fallback
214
+ buildId = Date.now().toString(36);
215
+ }
216
+ }
217
+
218
+ writeFileSync(resolve(distDir, "BUILD_ID"), buildId, "utf8");
219
+ console.log(` alab build ID → ${buildId}`);
220
+ }
221
+
222
+ /**
223
+ * Pre-render static HTML shells for every page that exports `ppr = true`.
224
+ *
225
+ * Runs AFTER the Vite SSR bundle so compiled page modules are available in
226
+ * `.alabjs/dist/server/`. Each shell is saved to `.alabjs/ppr-cache/`.
227
+ */
228
+ async function buildPPRShells(distDir: string, cwd: string): Promise<void> {
229
+ const manifestPath = resolve(distDir, "route-manifest.json");
230
+ if (!existsSync(manifestPath)) return;
231
+
232
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as RouteManifest;
233
+ const pageRoutes = manifest.routes.filter((r) => r.kind === "page");
234
+ const pprCacheDir = resolve(cwd, PPR_CACHE_SUBDIR);
235
+ let count = 0;
236
+
237
+ for (const route of pageRoutes) {
238
+ const modulePath = resolve(distDir, "server", route.file);
239
+ if (!existsSync(modulePath)) continue;
240
+
241
+ // Dynamic import — module is compiled ESM, importable by Node directly.
242
+ const mod = await import(modulePath) as {
243
+ default?: unknown;
244
+ ppr?: unknown;
245
+ metadata?: Record<string, unknown>;
246
+ };
247
+
248
+ if (mod.ppr !== true) continue;
249
+ if (typeof mod.default !== "function") {
250
+ console.warn(` alab ppr: ${route.file} has no default export — skipping.`);
251
+ continue;
252
+ }
253
+
254
+ // Load layout modules (outermost → innermost).
255
+ const layoutPaths = findBuildLayoutFiles(route.file, distDir);
256
+ const layoutMods = await Promise.all(
257
+ layoutPaths.map((p) => import(resolve(distDir, "server", p))),
258
+ );
259
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
260
+ const layouts = layoutMods.map((m: any) => m.default).filter((c: unknown) => typeof c === "function");
261
+
262
+ try {
263
+ await preRenderPPRShell({
264
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
265
+ Page: mod.default as any,
266
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
267
+ layouts: layouts as any[],
268
+ shellOpts: {
269
+ metadata: (mod.metadata as never) ?? {},
270
+ paramsJson: "{}",
271
+ searchParamsJson: "{}",
272
+ routeFile: route.file,
273
+ ssr: true,
274
+ },
275
+ pprCacheDir,
276
+ routePath: route.path,
277
+ });
278
+ count++;
279
+ } catch (err) {
280
+ const msg = err instanceof Error ? err.message : String(err);
281
+ console.warn(` alab ppr: failed to pre-render ${route.path}: ${msg}`);
282
+ }
283
+ }
284
+
285
+ if (count > 0) {
286
+ console.log(` alab ppr → ${count} shell${count === 1 ? "" : "s"} written to ${PPR_CACHE_SUBDIR}`);
287
+ }
288
+ }
289
+
175
290
  /** Compile the offline service worker to a standalone iife bundle. */
176
291
  async function buildOfflineSw(cwd: string): Promise<void> {
177
292
  const swEntry = new URL("../client/offline-sw.js", import.meta.url).pathname;
@@ -63,6 +63,12 @@ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
63
63
  export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions) {
64
64
  console.log(" alab starting dev server...\n");
65
65
 
66
+ // Per-session build ID for skew protection in dev.
67
+ // A new ID is generated each time the dev server starts so that a browser
68
+ // tab left open across a restart will hard-reload on the next navigation
69
+ // rather than silently rendering with stale JS.
70
+ const devBuildId = `dev-${Date.now().toString(36)}`;
71
+
66
72
  const appDir = resolve(cwd, "app");
67
73
 
68
74
  const vite = await createServer({
@@ -459,6 +465,7 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
459
465
  layoutsJson,
460
466
  loadingFile,
461
467
  ssr: ssrEnabled,
468
+ buildId: devBuildId,
462
469
  });
463
470
  const shellAfter = htmlShellAfter({});
464
471
  const rawHtml = `${shellBefore}${ssrContent}${shellAfter}`;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Alab Analytics — Core Web Vitals collection component.
3
+ *
4
+ * Drop `<Analytics />` into your root layout to start collecting
5
+ * LCP, CLS, INP, TTFB, and FCP from real users. Metrics are sent
6
+ * to `/_alabjs/vitals` via `navigator.sendBeacon` and aggregated
7
+ * in memory on the server.
8
+ *
9
+ * ## Usage
10
+ *
11
+ * ```tsx
12
+ * // app/layout.tsx
13
+ * import { Analytics } from "alabjs/components";
14
+ *
15
+ * export default function RootLayout({ children }: { children: React.ReactNode }) {
16
+ * return (
17
+ * <>
18
+ * {children}
19
+ * <Analytics />
20
+ * </>
21
+ * );
22
+ * }
23
+ * ```
24
+ *
25
+ * ## Viewing metrics
26
+ *
27
+ * ```sh
28
+ * curl -H "Authorization: Bearer $ALAB_ANALYTICS_SECRET" \
29
+ * http://localhost:3000/_alabjs/analytics
30
+ * ```
31
+ */
32
+
33
+ import { useEffect } from "react";
34
+
35
+ export interface AnalyticsProps {
36
+ /**
37
+ * Override the beacon endpoint.
38
+ * @default "/_alabjs/vitals"
39
+ */
40
+ endpoint?: string;
41
+ }
42
+
43
+ /**
44
+ * Collects Core Web Vitals (LCP, CLS, INP, TTFB, FCP) using the browser's
45
+ * `PerformanceObserver` API and sends them to the AlabJS vitals endpoint.
46
+ *
47
+ * Uses `navigator.sendBeacon` so beacons are fire-and-forget and survive
48
+ * page unloads. Implements `buffered: true` so metrics already emitted
49
+ * before the observer attached are still captured.
50
+ */
51
+ export function Analytics({ endpoint = "/_alabjs/vitals" }: AnalyticsProps) {
52
+ useEffect(() => {
53
+ if (typeof window === "undefined" || typeof PerformanceObserver === "undefined") return;
54
+
55
+ const route = window.location.pathname;
56
+
57
+ function send(name: string, value: number) {
58
+ const payload = JSON.stringify({ name, value, route });
59
+ if (navigator.sendBeacon) {
60
+ navigator.sendBeacon(endpoint, new Blob([payload], { type: "application/json" }));
61
+ } else {
62
+ // Fallback for environments without sendBeacon (rare).
63
+ void fetch(endpoint, {
64
+ method: "POST",
65
+ body: payload,
66
+ headers: { "content-type": "application/json" },
67
+ keepalive: true,
68
+ }).catch(() => {});
69
+ }
70
+ }
71
+
72
+ const observers: PerformanceObserver[] = [];
73
+
74
+ function observe(type: string, cb: (entries: PerformanceObserverEntryList) => void) {
75
+ try {
76
+ const po = new PerformanceObserver(cb);
77
+ po.observe({ type, buffered: true });
78
+ observers.push(po);
79
+ } catch {
80
+ // Entry type not supported in this browser — skip silently.
81
+ }
82
+ }
83
+
84
+ // ── LCP — Largest Contentful Paint ─────────────────────────────────────
85
+ // We report the last entry since LCP can be updated multiple times.
86
+ let lcpValue = 0;
87
+ observe("largest-contentful-paint", (list) => {
88
+ const entries = list.getEntries();
89
+ const last = entries[entries.length - 1] as PerformancePaintTiming | undefined;
90
+ if (last) lcpValue = last.startTime;
91
+ });
92
+
93
+ // ── CLS — Cumulative Layout Shift ───────────────────────────────────────
94
+ // Accumulate shift values across all layout-shift entries.
95
+ let clsValue = 0;
96
+ let clsSessionGap = 0;
97
+ let clsSessionValue = 0;
98
+ let clsLastTime = 0;
99
+ observe("layout-shift", (list) => {
100
+ for (const entry of list.getEntries()) {
101
+ const ls = entry as PerformanceEntry & { value: number; hadRecentInput: boolean };
102
+ if (ls.hadRecentInput) continue;
103
+ const gap = ls.startTime - clsLastTime;
104
+ if (gap > 1000 || ls.startTime - clsSessionGap > 5000) {
105
+ clsSessionValue = ls.value;
106
+ clsSessionGap = ls.startTime;
107
+ } else {
108
+ clsSessionValue += ls.value;
109
+ }
110
+ clsLastTime = ls.startTime;
111
+ if (clsSessionValue > clsValue) clsValue = clsSessionValue;
112
+ }
113
+ });
114
+
115
+ // ── INP — Interaction to Next Paint ────────────────────────────────────
116
+ // Track the worst interaction duration (p98 heuristic: worst of all events).
117
+ let inpValue = 0;
118
+ observe("event", (list) => {
119
+ for (const entry of list.getEntries()) {
120
+ const e = entry as PerformanceEntry & { duration: number };
121
+ if (e.duration > inpValue) inpValue = e.duration;
122
+ }
123
+ });
124
+
125
+ // ── TTFB — Time to First Byte ───────────────────────────────────────────
126
+ // Read directly from navigation timing — available immediately after load.
127
+ const sendTtfb = () => {
128
+ const nav = performance.getEntriesByType("navigation")[0] as
129
+ | PerformanceNavigationTiming
130
+ | undefined;
131
+ if (nav) send("TTFB", nav.responseStart);
132
+ };
133
+
134
+ // ── FCP — First Contentful Paint ───────────────────────────────────────
135
+ observe("paint", (list) => {
136
+ for (const entry of list.getEntries()) {
137
+ if (entry.name === "first-contentful-paint") {
138
+ send("FCP", entry.startTime);
139
+ }
140
+ }
141
+ });
142
+
143
+ // Flush LCP, CLS, and INP on page hide (navigating away / tab close).
144
+ // This gives us the most accurate final values.
145
+ const flush = () => {
146
+ if (lcpValue > 0) send("LCP", lcpValue);
147
+ if (clsValue > 0) send("CLS", Math.round(clsValue * 10000) / 10000);
148
+ if (inpValue > 0) send("INP", inpValue);
149
+ };
150
+
151
+ sendTtfb();
152
+ window.addEventListener("visibilitychange", () => {
153
+ if (document.visibilityState === "hidden") flush();
154
+ }, { once: true });
155
+
156
+ return () => {
157
+ for (const po of observers) po.disconnect();
158
+ };
159
+ // endpoint is intentionally captured once on mount only.
160
+ // eslint-disable-next-line react-hooks/exhaustive-deps
161
+ }, []);
162
+
163
+ return null;
164
+ }