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.
- 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/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +104 -2
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +6 -0
- package/dist/commands/dev.js.map +1 -1
- 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/Dynamic.d.ts +88 -0
- package/dist/components/Dynamic.d.ts.map +1 -0
- package/dist/components/Dynamic.js +86 -0
- package/dist/components/Dynamic.js.map +1 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +2 -0
- package/dist/components/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/app.js +72 -8
- package/dist/server/app.js.map +1 -1
- package/dist/server/cdn.d.ts +72 -0
- package/dist/server/cdn.d.ts.map +1 -0
- package/dist/server/cdn.js +132 -0
- package/dist/server/cdn.js.map +1 -0
- package/dist/server/revalidate.d.ts.map +1 -1
- package/dist/server/revalidate.js +6 -1
- package/dist/server/revalidate.js.map +1 -1
- package/dist/ssr/html.d.ts +7 -0
- package/dist/ssr/html.d.ts.map +1 -1
- package/dist/ssr/html.js +2 -1
- package/dist/ssr/html.js.map +1 -1
- package/dist/ssr/ppr.d.ts +69 -0
- package/dist/ssr/ppr.d.ts.map +1 -0
- package/dist/ssr/ppr.js +132 -0
- package/dist/ssr/ppr.js.map +1 -0
- package/dist/ssr/render.d.ts +2 -0
- package/dist/ssr/render.d.ts.map +1 -1
- package/dist/ssr/render.js +2 -1
- package/dist/ssr/render.js.map +1 -1
- package/dist/types/index.d.ts +20 -1
- package/dist/types/index.d.ts.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/commands/build.ts +117 -2
- package/src/commands/dev.ts +7 -0
- package/src/components/Analytics.tsx +164 -0
- package/src/components/Dynamic.tsx +124 -0
- package/src/components/index.ts +4 -0
- package/src/index.ts +1 -0
- package/src/server/app.ts +82 -9
- package/src/server/cdn.ts +187 -0
- package/src/server/revalidate.ts +7 -1
- package/src/ssr/html.ts +9 -0
- package/src/ssr/ppr.ts +167 -0
- package/src/ssr/render.ts +4 -0
- package/src/types/index.ts +23 -0
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alabjs",
|
|
3
|
-
"version": "0.
|
|
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
|
+
}
|
package/src/commands/build.ts
CHANGED
|
@@ -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;
|
package/src/commands/dev.ts
CHANGED
|
@@ -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
|
+
}
|