alabjs 0.2.0 → 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.
- package/dist/analytics/handler.d.ts +24 -0
- package/dist/analytics/handler.d.ts.map +1 -0
- package/dist/analytics/handler.js +87 -0
- package/dist/analytics/handler.js.map +1 -0
- package/dist/analytics/store.d.ts +33 -0
- package/dist/analytics/store.d.ts.map +1 -0
- package/dist/analytics/store.js +68 -0
- package/dist/analytics/store.js.map +1 -0
- package/dist/analytics/store.test.d.ts +2 -0
- package/dist/analytics/store.test.d.ts.map +1 -0
- package/dist/analytics/store.test.js +42 -0
- package/dist/analytics/store.test.js.map +1 -0
- package/dist/components/Analytics.d.ts +48 -0
- package/dist/components/Analytics.d.ts.map +1 -0
- package/dist/components/Analytics.js +154 -0
- package/dist/components/Analytics.js.map +1 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/index.js.map +1 -1
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/app.js +10 -0
- package/dist/server/app.js.map +1 -1
- package/package.json +5 -1
- package/src/analytics/handler.ts +110 -0
- package/src/analytics/store.test.ts +45 -0
- package/src/analytics/store.ts +94 -0
- package/src/components/Analytics.tsx +164 -0
- package/src/components/index.ts +2 -0
- package/src/server/app.ts +18 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
8
|
+
/**
|
|
9
|
+
* POST /_alabjs/vitals
|
|
10
|
+
*
|
|
11
|
+
* Accepts a JSON beacon: { name, value, route }
|
|
12
|
+
* Called by the browser via navigator.sendBeacon — no auth required.
|
|
13
|
+
* Always responds 204 so the browser doesn't wait.
|
|
14
|
+
*/
|
|
15
|
+
export declare function handleVitalsBeacon(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* GET /_alabjs/analytics
|
|
18
|
+
*
|
|
19
|
+
* Returns a JSON snapshot of all collected metrics.
|
|
20
|
+
* Protected by Authorization: Bearer <ALAB_ANALYTICS_SECRET>.
|
|
21
|
+
* Falls back to ALAB_REVALIDATE_SECRET if ALAB_ANALYTICS_SECRET is unset.
|
|
22
|
+
*/
|
|
23
|
+
export declare function handleAnalyticsDashboard(req: IncomingMessage, res: ServerResponse): void;
|
|
24
|
+
//# sourceMappingURL=handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/analytics/handler.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAejE;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,IAAI,CAAC,CA6Cf;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,GAClB,IAAI,CAoBN"}
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
import { recordMetric, getSnapshot } from "./store.js";
|
|
8
|
+
const METRIC_NAMES = new Set(["LCP", "CLS", "INP", "TTFB", "FCP"]);
|
|
9
|
+
/** Read the raw request body as a UTF-8 string. */
|
|
10
|
+
function readBody(req) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const chunks = [];
|
|
13
|
+
req.on("data", (c) => chunks.push(c));
|
|
14
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
15
|
+
req.on("error", reject);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* POST /_alabjs/vitals
|
|
20
|
+
*
|
|
21
|
+
* Accepts a JSON beacon: { name, value, route }
|
|
22
|
+
* Called by the browser via navigator.sendBeacon — no auth required.
|
|
23
|
+
* Always responds 204 so the browser doesn't wait.
|
|
24
|
+
*/
|
|
25
|
+
export async function handleVitalsBeacon(req, res) {
|
|
26
|
+
res.setHeader("access-control-allow-origin", "*");
|
|
27
|
+
if (req.method === "OPTIONS") {
|
|
28
|
+
res.setHeader("access-control-allow-methods", "POST, OPTIONS");
|
|
29
|
+
res.setHeader("access-control-allow-headers", "content-type");
|
|
30
|
+
res.statusCode = 204;
|
|
31
|
+
res.end();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (req.method !== "POST") {
|
|
35
|
+
res.statusCode = 405;
|
|
36
|
+
res.end();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const raw = await readBody(req);
|
|
41
|
+
// sendBeacon may batch multiple events as a JSON array or single object.
|
|
42
|
+
const parsed = JSON.parse(raw);
|
|
43
|
+
const events = Array.isArray(parsed) ? parsed : [parsed];
|
|
44
|
+
for (const ev of events) {
|
|
45
|
+
if (ev !== null &&
|
|
46
|
+
typeof ev === "object" &&
|
|
47
|
+
"name" in ev && typeof ev.name === "string" &&
|
|
48
|
+
"value" in ev && typeof ev.value === "number" &&
|
|
49
|
+
"route" in ev && typeof ev.route === "string" &&
|
|
50
|
+
METRIC_NAMES.has(ev.name)) {
|
|
51
|
+
recordMetric(ev.route.slice(0, 256), // cap length for safety
|
|
52
|
+
ev.name, ev.value);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Malformed beacon — swallow silently, still return 204.
|
|
58
|
+
}
|
|
59
|
+
res.statusCode = 204;
|
|
60
|
+
res.end();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* GET /_alabjs/analytics
|
|
64
|
+
*
|
|
65
|
+
* Returns a JSON snapshot of all collected metrics.
|
|
66
|
+
* Protected by Authorization: Bearer <ALAB_ANALYTICS_SECRET>.
|
|
67
|
+
* Falls back to ALAB_REVALIDATE_SECRET if ALAB_ANALYTICS_SECRET is unset.
|
|
68
|
+
*/
|
|
69
|
+
export function handleAnalyticsDashboard(req, res) {
|
|
70
|
+
const secret = process.env["ALAB_ANALYTICS_SECRET"] ??
|
|
71
|
+
process.env["ALAB_REVALIDATE_SECRET"];
|
|
72
|
+
if (secret) {
|
|
73
|
+
const auth = req.headers["authorization"] ?? "";
|
|
74
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
75
|
+
if (token !== secret) {
|
|
76
|
+
res.statusCode = 401;
|
|
77
|
+
res.setHeader("content-type", "application/json");
|
|
78
|
+
res.end(JSON.stringify({ error: "Unauthorized. Set Authorization: Bearer <ALAB_ANALYTICS_SECRET>." }));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
res.statusCode = 200;
|
|
83
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
84
|
+
res.setHeader("cache-control", "no-store");
|
|
85
|
+
res.end(JSON.stringify(getSnapshot(), null, 2));
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=handler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handler.js","sourceRoot":"","sources":["../../src/analytics/handler.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,YAAY,EAAE,WAAW,EAAmB,MAAM,YAAY,CAAC;AAExE,MAAM,YAAY,GAAG,IAAI,GAAG,CAAS,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC;AAE3E,mDAAmD;AACnD,SAAS,QAAQ,CAAC,GAAoB;IACpC,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,GAAG,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,CAAS,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QAC9C,GAAG,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QACrE,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAC1B,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,GAAoB,EACpB,GAAmB;IAEnB,GAAG,CAAC,SAAS,CAAC,6BAA6B,EAAE,GAAG,CAAC,CAAC;IAElD,IAAI,GAAG,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;QAC7B,GAAG,CAAC,SAAS,CAAC,8BAA8B,EAAE,eAAe,CAAC,CAAC;QAC/D,GAAG,CAAC,SAAS,CAAC,8BAA8B,EAAE,cAAc,CAAC,CAAC;QAC9D,GAAG,CAAC,UAAU,GAAG,GAAG,CAAC;QACrB,GAAG,CAAC,GAAG,EAAE,CAAC;QACV,OAAO;IACT,CAAC;IAED,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;QAC1B,GAAG,CAAC,UAAU,GAAG,GAAG,CAAC;QACrB,GAAG,CAAC,GAAG,EAAE,CAAC;QACV,OAAO;IACT,CAAC;IAED,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,GAAG,CAAC,CAAC;QAChC,yEAAyE;QACzE,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACxC,MAAM,MAAM,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;QAEzD,KAAK,MAAM,EAAE,IAAI,MAAM,EAAE,CAAC;YACxB,IACE,EAAE,KAAK,IAAI;gBACX,OAAO,EAAE,KAAK,QAAQ;gBACtB,MAAM,IAAI,EAAE,IAAI,OAAO,EAAE,CAAC,IAAI,KAAK,QAAQ;gBAC3C,OAAO,IAAI,EAAE,IAAI,OAAO,EAAE,CAAC,KAAK,KAAK,QAAQ;gBAC7C,OAAO,IAAI,EAAE,IAAI,OAAO,EAAE,CAAC,KAAK,KAAK,QAAQ;gBAC7C,YAAY,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EACzB,CAAC;gBACD,YAAY,CACV,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAS,wBAAwB;gBACvD,EAAE,CAAC,IAAkB,EACrB,EAAE,CAAC,KAAK,CACT,CAAC;YACJ,CAAC;QACH,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,yDAAyD;IAC3D,CAAC;IAED,GAAG,CAAC,UAAU,GAAG,GAAG,CAAC;IACrB,GAAG,CAAC,GAAG,EAAE,CAAC;AACZ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,wBAAwB,CACtC,GAAoB,EACpB,GAAmB;IAEnB,MAAM,MAAM,GACV,OAAO,CAAC,GAAG,CAAC,uBAAuB,CAAC;QACpC,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;IAExC,IAAI,MAAM,EAAE,CAAC;QACX,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC;QAChD,MAAM,KAAK,GAAG,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9D,IAAI,KAAK,KAAK,MAAM,EAAE,CAAC;YACrB,GAAG,CAAC,UAAU,GAAG,GAAG,CAAC;YACrB,GAAG,CAAC,SAAS,CAAC,cAAc,EAAE,kBAAkB,CAAC,CAAC;YAClD,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,kEAAkE,EAAE,CAAC,CAAC,CAAC;YACvG,OAAO;QACT,CAAC;IACH,CAAC;IAED,GAAG,CAAC,UAAU,GAAG,GAAG,CAAC;IACrB,GAAG,CAAC,SAAS,CAAC,cAAc,EAAE,iCAAiC,CAAC,CAAC;IACjE,GAAG,CAAC,SAAS,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;IAC3C,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,WAAW,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;AAClD,CAAC"}
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
export type MetricName = "LCP" | "CLS" | "INP" | "TTFB" | "FCP";
|
|
11
|
+
/**
|
|
12
|
+
* Record one metric sample for a route.
|
|
13
|
+
* If the ring buffer is full, the oldest sample is evicted.
|
|
14
|
+
*/
|
|
15
|
+
export declare function recordMetric(route: string, name: MetricName, value: number): void;
|
|
16
|
+
export interface RouteStats {
|
|
17
|
+
pageviews: number;
|
|
18
|
+
lcp_p75: number | null;
|
|
19
|
+
cls_p75: number | null;
|
|
20
|
+
inp_p75: number | null;
|
|
21
|
+
ttfb_p75: number | null;
|
|
22
|
+
fcp_p75: number | null;
|
|
23
|
+
}
|
|
24
|
+
export interface AnalyticsSnapshot {
|
|
25
|
+
routes: Record<string, RouteStats>;
|
|
26
|
+
/** ISO timestamp of when the snapshot was taken. */
|
|
27
|
+
asOf: string;
|
|
28
|
+
}
|
|
29
|
+
/** Return an aggregated snapshot of all collected metrics. */
|
|
30
|
+
export declare function getSnapshot(): AnalyticsSnapshot;
|
|
31
|
+
/** Clear all stored data (useful in tests). */
|
|
32
|
+
export declare function clearStore(): void;
|
|
33
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/analytics/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,CAAC;AAyBhE;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAOjF;AAUD,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACnC,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAC;CACd;AAED,8DAA8D;AAC9D,wBAAgB,WAAW,IAAI,iBAAiB,CAa/C;AAED,+CAA+C;AAC/C,wBAAgB,UAAU,IAAI,IAAI,CAEjC"}
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
/** Maximum samples retained per (route, metric) bucket. */
|
|
11
|
+
const RING_SIZE = 500;
|
|
12
|
+
const METRIC_NAMES = ["LCP", "CLS", "INP", "TTFB", "FCP"];
|
|
13
|
+
/** route path → bucket */
|
|
14
|
+
const store = new Map();
|
|
15
|
+
function getBucket(route) {
|
|
16
|
+
let bucket = store.get(route);
|
|
17
|
+
if (!bucket) {
|
|
18
|
+
const samples = {};
|
|
19
|
+
for (const m of METRIC_NAMES)
|
|
20
|
+
samples[m] = [];
|
|
21
|
+
bucket = { samples, pageviews: 0 };
|
|
22
|
+
store.set(route, bucket);
|
|
23
|
+
}
|
|
24
|
+
return bucket;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Record one metric sample for a route.
|
|
28
|
+
* If the ring buffer is full, the oldest sample is evicted.
|
|
29
|
+
*/
|
|
30
|
+
export function recordMetric(route, name, value) {
|
|
31
|
+
if (!METRIC_NAMES.includes(name))
|
|
32
|
+
return;
|
|
33
|
+
const bucket = getBucket(route);
|
|
34
|
+
const buf = bucket.samples[name];
|
|
35
|
+
buf.push(value);
|
|
36
|
+
if (buf.length > RING_SIZE)
|
|
37
|
+
buf.shift();
|
|
38
|
+
if (name === "LCP")
|
|
39
|
+
bucket.pageviews++;
|
|
40
|
+
}
|
|
41
|
+
/** Compute the p75 of an array of numbers, or null if empty. */
|
|
42
|
+
function p75(values) {
|
|
43
|
+
if (values.length === 0)
|
|
44
|
+
return null;
|
|
45
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
46
|
+
const idx = Math.floor(sorted.length * 0.75);
|
|
47
|
+
return Math.round((sorted[idx] ?? sorted[sorted.length - 1]) * 100) / 100;
|
|
48
|
+
}
|
|
49
|
+
/** Return an aggregated snapshot of all collected metrics. */
|
|
50
|
+
export function getSnapshot() {
|
|
51
|
+
const routes = {};
|
|
52
|
+
for (const [route, bucket] of store.entries()) {
|
|
53
|
+
routes[route] = {
|
|
54
|
+
pageviews: bucket.pageviews,
|
|
55
|
+
lcp_p75: p75(bucket.samples.LCP),
|
|
56
|
+
cls_p75: p75(bucket.samples.CLS),
|
|
57
|
+
inp_p75: p75(bucket.samples.INP),
|
|
58
|
+
ttfb_p75: p75(bucket.samples.TTFB),
|
|
59
|
+
fcp_p75: p75(bucket.samples.FCP),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { routes, asOf: new Date().toISOString() };
|
|
63
|
+
}
|
|
64
|
+
/** Clear all stored data (useful in tests). */
|
|
65
|
+
export function clearStore() {
|
|
66
|
+
store.clear();
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/analytics/store.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.test.d.ts","sourceRoot":"","sources":["../../src/analytics/store.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { recordMetric, getSnapshot, clearStore } from "./store.js";
|
|
3
|
+
beforeEach(() => clearStore());
|
|
4
|
+
describe("recordMetric / getSnapshot", () => {
|
|
5
|
+
it("records a single LCP and counts it as a pageview", () => {
|
|
6
|
+
recordMetric("/blog", "LCP", 1200);
|
|
7
|
+
const snap = getSnapshot();
|
|
8
|
+
expect(snap.routes["/blog"]?.pageviews).toBe(1);
|
|
9
|
+
expect(snap.routes["/blog"]?.lcp_p75).toBe(1200);
|
|
10
|
+
});
|
|
11
|
+
it("computes p75 correctly across multiple samples", () => {
|
|
12
|
+
// 4 samples: [100, 200, 300, 400] → sorted → index floor(4*0.75)=3 → 400
|
|
13
|
+
for (const v of [300, 100, 400, 200])
|
|
14
|
+
recordMetric("/", "LCP", v);
|
|
15
|
+
const snap = getSnapshot();
|
|
16
|
+
expect(snap.routes["/"]?.lcp_p75).toBe(400);
|
|
17
|
+
});
|
|
18
|
+
it("evicts oldest sample when ring buffer is full", () => {
|
|
19
|
+
// Fill 500 samples with value 1, then add one with value 9999
|
|
20
|
+
for (let i = 0; i < 500; i++)
|
|
21
|
+
recordMetric("/test", "FCP", 1);
|
|
22
|
+
recordMetric("/test", "FCP", 9999);
|
|
23
|
+
const snap = getSnapshot();
|
|
24
|
+
// Ring should still have exactly 500 entries (oldest 1 evicted, 9999 added)
|
|
25
|
+
// p75 of 499×1 + 1×9999 = index 374 → 1
|
|
26
|
+
expect(snap.routes["/test"]?.fcp_p75).toBe(1);
|
|
27
|
+
});
|
|
28
|
+
it("ignores unknown metric names", () => {
|
|
29
|
+
// @ts-expect-error intentionally invalid
|
|
30
|
+
recordMetric("/x", "UNKNOWN", 100);
|
|
31
|
+
const snap = getSnapshot();
|
|
32
|
+
expect(snap.routes["/x"]).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
it("tracks multiple routes independently", () => {
|
|
35
|
+
recordMetric("/a", "CLS", 0.05);
|
|
36
|
+
recordMetric("/b", "CLS", 0.2);
|
|
37
|
+
const snap = getSnapshot();
|
|
38
|
+
expect(snap.routes["/a"]?.cls_p75).toBe(0.05);
|
|
39
|
+
expect(snap.routes["/b"]?.cls_p75).toBe(0.2);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
//# sourceMappingURL=store.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.test.js","sourceRoot":"","sources":["../../src/analytics/store.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAC1D,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEnE,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,EAAE,CAAC,CAAC;AAE/B,QAAQ,CAAC,4BAA4B,EAAE,GAAG,EAAE;IAC1C,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC1D,YAAY,CAAC,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC;QACnC,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;QAC3B,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAChD,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACnD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gDAAgD,EAAE,GAAG,EAAE;QACxD,yEAAyE;QACzE,KAAK,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC;YAAE,YAAY,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;QAClE,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;QAC3B,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC9C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+CAA+C,EAAE,GAAG,EAAE;QACvD,8DAA8D;QAC9D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,EAAE,CAAC,EAAE;YAAE,YAAY,CAAC,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;QAC9D,YAAY,CAAC,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC;QACnC,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;QAC3B,4EAA4E;QAC5E,wCAAwC;QACxC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAChD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,8BAA8B,EAAE,GAAG,EAAE;QACtC,yCAAyC;QACzC,YAAY,CAAC,IAAI,EAAE,SAAS,EAAE,GAAG,CAAC,CAAC;QACnC,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;QAC3B,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;IAC5C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE;QAC9C,YAAY,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC;QAChC,YAAY,CAAC,IAAI,EAAE,KAAK,EAAE,GAAG,CAAC,CAAC;QAC/B,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;QAC3B,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC9C,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
export interface AnalyticsProps {
|
|
33
|
+
/**
|
|
34
|
+
* Override the beacon endpoint.
|
|
35
|
+
* @default "/_alabjs/vitals"
|
|
36
|
+
*/
|
|
37
|
+
endpoint?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Collects Core Web Vitals (LCP, CLS, INP, TTFB, FCP) using the browser's
|
|
41
|
+
* `PerformanceObserver` API and sends them to the AlabJS vitals endpoint.
|
|
42
|
+
*
|
|
43
|
+
* Uses `navigator.sendBeacon` so beacons are fire-and-forget and survive
|
|
44
|
+
* page unloads. Implements `buffered: true` so metrics already emitted
|
|
45
|
+
* before the observer attached are still captured.
|
|
46
|
+
*/
|
|
47
|
+
export declare function Analytics({ endpoint }: AnalyticsProps): null;
|
|
48
|
+
//# sourceMappingURL=Analytics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Analytics.d.ts","sourceRoot":"","sources":["../../src/components/Analytics.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAIH,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,EAAE,QAA4B,EAAE,EAAE,cAAc,QAiHzE"}
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
import { useEffect } from "react";
|
|
33
|
+
/**
|
|
34
|
+
* Collects Core Web Vitals (LCP, CLS, INP, TTFB, FCP) using the browser's
|
|
35
|
+
* `PerformanceObserver` API and sends them to the AlabJS vitals endpoint.
|
|
36
|
+
*
|
|
37
|
+
* Uses `navigator.sendBeacon` so beacons are fire-and-forget and survive
|
|
38
|
+
* page unloads. Implements `buffered: true` so metrics already emitted
|
|
39
|
+
* before the observer attached are still captured.
|
|
40
|
+
*/
|
|
41
|
+
export function Analytics({ endpoint = "/_alabjs/vitals" }) {
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (typeof window === "undefined" || typeof PerformanceObserver === "undefined")
|
|
44
|
+
return;
|
|
45
|
+
const route = window.location.pathname;
|
|
46
|
+
function send(name, value) {
|
|
47
|
+
const payload = JSON.stringify({ name, value, route });
|
|
48
|
+
if (navigator.sendBeacon) {
|
|
49
|
+
navigator.sendBeacon(endpoint, new Blob([payload], { type: "application/json" }));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// Fallback for environments without sendBeacon (rare).
|
|
53
|
+
void fetch(endpoint, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
body: payload,
|
|
56
|
+
headers: { "content-type": "application/json" },
|
|
57
|
+
keepalive: true,
|
|
58
|
+
}).catch(() => { });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const observers = [];
|
|
62
|
+
function observe(type, cb) {
|
|
63
|
+
try {
|
|
64
|
+
const po = new PerformanceObserver(cb);
|
|
65
|
+
po.observe({ type, buffered: true });
|
|
66
|
+
observers.push(po);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Entry type not supported in this browser — skip silently.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// ── LCP — Largest Contentful Paint ─────────────────────────────────────
|
|
73
|
+
// We report the last entry since LCP can be updated multiple times.
|
|
74
|
+
let lcpValue = 0;
|
|
75
|
+
observe("largest-contentful-paint", (list) => {
|
|
76
|
+
const entries = list.getEntries();
|
|
77
|
+
const last = entries[entries.length - 1];
|
|
78
|
+
if (last)
|
|
79
|
+
lcpValue = last.startTime;
|
|
80
|
+
});
|
|
81
|
+
// ── CLS — Cumulative Layout Shift ───────────────────────────────────────
|
|
82
|
+
// Accumulate shift values across all layout-shift entries.
|
|
83
|
+
let clsValue = 0;
|
|
84
|
+
let clsSessionGap = 0;
|
|
85
|
+
let clsSessionValue = 0;
|
|
86
|
+
let clsLastTime = 0;
|
|
87
|
+
observe("layout-shift", (list) => {
|
|
88
|
+
for (const entry of list.getEntries()) {
|
|
89
|
+
const ls = entry;
|
|
90
|
+
if (ls.hadRecentInput)
|
|
91
|
+
continue;
|
|
92
|
+
const gap = ls.startTime - clsLastTime;
|
|
93
|
+
if (gap > 1000 || ls.startTime - clsSessionGap > 5000) {
|
|
94
|
+
clsSessionValue = ls.value;
|
|
95
|
+
clsSessionGap = ls.startTime;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
clsSessionValue += ls.value;
|
|
99
|
+
}
|
|
100
|
+
clsLastTime = ls.startTime;
|
|
101
|
+
if (clsSessionValue > clsValue)
|
|
102
|
+
clsValue = clsSessionValue;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
// ── INP — Interaction to Next Paint ────────────────────────────────────
|
|
106
|
+
// Track the worst interaction duration (p98 heuristic: worst of all events).
|
|
107
|
+
let inpValue = 0;
|
|
108
|
+
observe("event", (list) => {
|
|
109
|
+
for (const entry of list.getEntries()) {
|
|
110
|
+
const e = entry;
|
|
111
|
+
if (e.duration > inpValue)
|
|
112
|
+
inpValue = e.duration;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
// ── TTFB — Time to First Byte ───────────────────────────────────────────
|
|
116
|
+
// Read directly from navigation timing — available immediately after load.
|
|
117
|
+
const sendTtfb = () => {
|
|
118
|
+
const nav = performance.getEntriesByType("navigation")[0];
|
|
119
|
+
if (nav)
|
|
120
|
+
send("TTFB", nav.responseStart);
|
|
121
|
+
};
|
|
122
|
+
// ── FCP — First Contentful Paint ───────────────────────────────────────
|
|
123
|
+
observe("paint", (list) => {
|
|
124
|
+
for (const entry of list.getEntries()) {
|
|
125
|
+
if (entry.name === "first-contentful-paint") {
|
|
126
|
+
send("FCP", entry.startTime);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
// Flush LCP, CLS, and INP on page hide (navigating away / tab close).
|
|
131
|
+
// This gives us the most accurate final values.
|
|
132
|
+
const flush = () => {
|
|
133
|
+
if (lcpValue > 0)
|
|
134
|
+
send("LCP", lcpValue);
|
|
135
|
+
if (clsValue > 0)
|
|
136
|
+
send("CLS", Math.round(clsValue * 10000) / 10000);
|
|
137
|
+
if (inpValue > 0)
|
|
138
|
+
send("INP", inpValue);
|
|
139
|
+
};
|
|
140
|
+
sendTtfb();
|
|
141
|
+
window.addEventListener("visibilitychange", () => {
|
|
142
|
+
if (document.visibilityState === "hidden")
|
|
143
|
+
flush();
|
|
144
|
+
}, { once: true });
|
|
145
|
+
return () => {
|
|
146
|
+
for (const po of observers)
|
|
147
|
+
po.disconnect();
|
|
148
|
+
};
|
|
149
|
+
// endpoint is intentionally captured once on mount only.
|
|
150
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
151
|
+
}, []);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=Analytics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Analytics.js","sourceRoot":"","sources":["../../src/components/Analytics.tsx"],"names":[],"mappings":"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"}
|
|
@@ -9,4 +9,6 @@ export { Font } from "./Font.js";
|
|
|
9
9
|
export type { FontProps } from "./Font.js";
|
|
10
10
|
export { Dynamic, PPRShellProvider } from "./Dynamic.js";
|
|
11
11
|
export type { DynamicProps } from "./Dynamic.js";
|
|
12
|
+
export { Analytics } from "./Analytics.js";
|
|
13
|
+
export type { AnalyticsProps } from "./Analytics.js";
|
|
12
14
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAC5D,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,YAAY,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,YAAY,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACzD,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAC5D,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,YAAY,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,YAAY,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACzD,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3C,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC"}
|
package/dist/components/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE5D,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAErC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE5D,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAErC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAEzD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC"}
|
package/dist/server/app.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../src/server/app.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;
|
|
1
|
+
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../../src/server/app.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AA2D3D,MAAM,WAAW,OAAO;IACtB,MAAM,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,QAAQ,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CA+c3E"}
|
package/dist/server/app.js
CHANGED
|
@@ -11,6 +11,7 @@ import { runMiddleware } from "./middleware.js";
|
|
|
11
11
|
import { checkRevalidateAuth, applyRevalidate } from "./revalidate.js";
|
|
12
12
|
import { applyCdnHeaders } from "./cdn.js";
|
|
13
13
|
import { getPPRShell, injectBuildIdIntoPPRShell, PPR_CACHE_SUBDIR } from "../ssr/ppr.js";
|
|
14
|
+
import { handleVitalsBeacon, handleAnalyticsDashboard } from "../analytics/handler.js";
|
|
14
15
|
/**
|
|
15
16
|
* Find layout file paths (relative to cwd root) for a given route.file, ordered outermost→innermost.
|
|
16
17
|
* Checks the compiled dist directory for the existence of each layout.
|
|
@@ -220,6 +221,15 @@ export function createApp(manifest, distDir) {
|
|
|
220
221
|
}
|
|
221
222
|
return JSON.stringify(result);
|
|
222
223
|
}));
|
|
224
|
+
// Core Web Vitals beacon — receives POST from <Analytics> component
|
|
225
|
+
router.post("/_alabjs/vitals", defineEventHandler((event) => {
|
|
226
|
+
return handleVitalsBeacon(event.node.req, event.node.res);
|
|
227
|
+
}));
|
|
228
|
+
// Analytics dashboard — GET aggregated per-route stats
|
|
229
|
+
router.get("/_alabjs/analytics", defineEventHandler((event) => {
|
|
230
|
+
handleAnalyticsDashboard(event.node.req, event.node.res);
|
|
231
|
+
return null;
|
|
232
|
+
}));
|
|
223
233
|
// Auto sitemap.xml from route manifest
|
|
224
234
|
router.get("/sitemap.xml", defineEventHandler((event) => {
|
|
225
235
|
const baseUrl = process.env["PUBLIC_URL"] ??
|