Skip to content

Commit 7773168

Browse files
committed
feat(lighthouse): add locale-aware local audits and server playbook
1 parent 9923bc8 commit 7773168

3 files changed

Lines changed: 214 additions & 27 deletions

File tree

docs/lighthouse-server-playbook.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Lighthouse Server URL Playbook (Nginx/CDN)
2+
3+
## Goal
4+
Run Lighthouse on the production server URL with the same baseline as local production output and avoid false negatives.
5+
6+
## 1) Preconditions
7+
- Build locale and URL path must match.
8+
- Server must serve the exported directory without rewriting `_next/static` incorrectly.
9+
- No 4xx/5xx for HTML, JS, CSS, fonts, and images required by the audited page.
10+
11+
## 2) Required Caching Policy
12+
- `/_next/static/*`: `Cache-Control: public, max-age=31536000, immutable`
13+
- Versioned public assets (hashed filenames): `Cache-Control: public, max-age=31536000, immutable`
14+
- HTML documents: `Cache-Control: no-cache` (or short max-age)
15+
16+
## 3) Quick Header Validation
17+
Use these checks before Lighthouse:
18+
19+
```bash
20+
curl -I https://<host>/<locale>/
21+
curl -I https://<host>/<locale>/_next/static/css/<file>.css
22+
curl -I https://<host>/<locale>/_next/static/chunks/<file>.js
23+
curl -I https://<host>/<locale>/_next/static/media/<file>.woff2
24+
```
25+
26+
Expected:
27+
- HTML returns 200 and no long-lived immutable cache.
28+
- `_next/static/*` returns 200 with long-lived immutable cache.
29+
30+
## 4) Run Lighthouse
31+
```bash
32+
node scripts/lighthouse-codex.mjs --url https://<host>/<locale>/
33+
```
34+
35+
## 5) Acceptance Criteria
36+
- `errors-in-console` has no 404 for critical assets.
37+
- No systematic 404 under `<locale>/_next/static/*`.
38+
- `network-dependency-tree-insight` and `render-blocking-insight` are evaluated only after 404 issues are resolved.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
"start": "next start",
1414
"lint": "eslint",
1515
"lighthouse:codex": "node scripts/lighthouse-codex.mjs",
16-
"lighthouse:codex:local": "node scripts/lighthouse-codex.mjs --local --dir out --port 4173"
16+
"lighthouse:codex:local": "node scripts/lighthouse-codex.mjs --local --dir out --port 4173",
17+
"lighthouse:codex:local:en": "pnpm build:en && node scripts/lighthouse-codex.mjs --local --dir out --port 4173 --path /en/",
18+
"lighthouse:codex:local:tr": "pnpm build:tr && node scripts/lighthouse-codex.mjs --local --dir out --port 4173 --path /tr/"
1719
},
1820
"dependencies": {
1921
"motion": "^12.40.0",

scripts/lighthouse-codex.mjs

Lines changed: 173 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
#!/usr/bin/env node
22

33
import { spawn } from "node:child_process";
4-
import { mkdir, readFile, writeFile } from "node:fs/promises";
4+
import { createServer } from "node:http";
5+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
56
import path from "node:path";
67
import process from "node:process";
78

@@ -34,15 +35,17 @@ async function main() {
3435
const mode = args.local ? "local" : "url";
3536
const auditUrl = await resolveAuditUrl();
3637

37-
let localServerProcess = null;
38+
let localServer = null;
3839

3940
try {
4041
if (args.local) {
4142
const localDir = String(args.dir ?? DEFAULT_LOCAL_DIR);
4243
const port = Number(args.port ?? DEFAULT_PORT);
44+
const host = String(args.host ?? DEFAULT_HOST);
4345

44-
localServerProcess = startStaticServer(localDir, port);
46+
localServer = await startStaticServer(localDir, host, port);
4547
await waitForUrl(auditUrl, 30_000);
48+
await runLocalPreflight(auditUrl);
4649
}
4750

4851
await runLighthouse({
@@ -67,8 +70,8 @@ async function main() {
6770
console.log(`- Raw JSON: ${reportJsonPath}`);
6871
console.log(`- Codex MD: ${reportMarkdownPath}`);
6972
} finally {
70-
if (localServerProcess) {
71-
localServerProcess.kill("SIGTERM");
73+
if (localServer) {
74+
await stopStaticServer(localServer);
7275
}
7376
}
7477
}
@@ -89,35 +92,118 @@ async function resolveAuditUrl() {
8992
return String(args.url);
9093
}
9194

92-
function startStaticServer(staticDir, port) {
93-
console.log(`Starting local static server: ${staticDir} on port ${port}`);
95+
async function startStaticServer(staticDir, host, port) {
96+
console.log(`Starting local static server: ${staticDir} on ${host}:${port}`);
97+
const rootDir = path.resolve(staticDir);
9498

95-
const child = spawn(
96-
getNpxCommand(),
97-
["-y", "serve", staticDir, "-l", String(port)],
98-
{
99-
stdio: ["ignore", "pipe", "pipe"],
100-
shell: false,
99+
const server = createServer(async (request, response) => {
100+
try {
101+
const requestUrl = new URL(request.url ?? "/", `http://${host}:${port}`);
102+
const pathname = decodeURIComponent(requestUrl.pathname);
103+
const filePath = await resolveFilePath(rootDir, pathname);
104+
105+
if (!filePath) {
106+
response.writeHead(404, { "Cache-Control": "no-store" });
107+
response.end("Not Found");
108+
return;
109+
}
110+
111+
const headers = getResponseHeaders(filePath);
112+
response.writeHead(200, headers);
113+
114+
if (request.method === "HEAD") {
115+
response.end();
116+
return;
117+
}
118+
119+
const content = await readFile(filePath);
120+
response.end(content);
121+
} catch (error) {
122+
response.writeHead(500, { "Cache-Control": "no-store" });
123+
response.end("Internal Server Error");
124+
console.error("[serve] request handling failed:", error);
101125
}
102-
);
126+
});
103127

104-
child.stdout.on("data", (chunk) => {
105-
const text = chunk.toString().trim();
106-
if (text) console.log(`[serve] ${text}`);
128+
await new Promise((resolve, reject) => {
129+
server.once("error", reject);
130+
server.listen(port, host, () => resolve());
107131
});
108132

109-
child.stderr.on("data", (chunk) => {
110-
const text = chunk.toString().trim();
111-
if (text) console.error(`[serve] ${text}`);
133+
return server;
134+
}
135+
136+
async function stopStaticServer(server) {
137+
await new Promise((resolve, reject) => {
138+
server.close((error) => {
139+
if (error) {
140+
reject(error);
141+
return;
142+
}
143+
resolve();
144+
});
112145
});
146+
}
147+
148+
async function resolveFilePath(rootDir, pathname) {
149+
const normalizedPath = pathname === "/" ? "/index.html" : pathname;
150+
const requestPath = normalizedPath.endsWith("/")
151+
? `${normalizedPath}index.html`
152+
: normalizedPath;
153+
154+
const candidates = [requestPath];
155+
const withoutFirstSegment = requestPath.replace(/^\/[^/]+(?=\/)/, "");
156+
if (withoutFirstSegment && withoutFirstSegment !== requestPath) {
157+
candidates.push(withoutFirstSegment);
158+
}
113159

114-
child.on("exit", (code) => {
115-
if (code !== null && code !== 0) {
116-
console.error(`Local static server exited with code ${code}`);
160+
for (const candidate of candidates) {
161+
const absolutePath = path.resolve(rootDir, `.${candidate}`);
162+
if (!absolutePath.startsWith(rootDir)) {
163+
continue;
117164
}
118-
});
165+
if (await fileExists(absolutePath)) {
166+
return absolutePath;
167+
}
168+
}
169+
170+
return null;
171+
}
172+
173+
function getResponseHeaders(filePath) {
174+
const extension = path.extname(filePath);
175+
const contentType = CONTENT_TYPES[extension] ?? "application/octet-stream";
176+
const cacheControl = getCacheControlForPath(filePath);
177+
178+
return {
179+
"Content-Type": contentType,
180+
"Cache-Control": cacheControl,
181+
};
182+
}
183+
184+
function getCacheControlForPath(filePath) {
185+
if (filePath.includes(`${path.sep}_next${path.sep}static${path.sep}`)) {
186+
return "public, max-age=31536000, immutable";
187+
}
188+
189+
if (/\.[0-9a-f]{8,}\./i.test(path.basename(filePath))) {
190+
return "public, max-age=31536000, immutable";
191+
}
192+
193+
if (filePath.endsWith(".html")) {
194+
return "no-cache";
195+
}
196+
197+
return "public, max-age=3600";
198+
}
119199

120-
return child;
200+
async function fileExists(filePath) {
201+
try {
202+
const entry = await stat(filePath);
203+
return entry.isFile();
204+
} catch {
205+
return false;
206+
}
121207
}
122208

123209
async function runLighthouse({
@@ -338,6 +424,55 @@ async function waitForUrl(url, timeoutMs) {
338424
throw new Error(`Local server did not become ready: ${waitUrl.toString()}`);
339425
}
340426

427+
async function runLocalPreflight(auditUrl) {
428+
const preflightUrls = new Set([auditUrl]);
429+
const html = await fetchText(auditUrl);
430+
const assetPaths = extractAssetPaths(html);
431+
const baseUrl = new URL(auditUrl);
432+
433+
for (const assetPath of assetPaths) {
434+
preflightUrls.add(new URL(assetPath, baseUrl).toString());
435+
}
436+
437+
const failures = [];
438+
439+
for (const url of preflightUrls) {
440+
const response = await fetch(url, { method: "HEAD" });
441+
if (response.status >= 400) {
442+
failures.push(`${response.status} ${url}`);
443+
}
444+
}
445+
446+
if (failures.length > 0) {
447+
throw new Error(
448+
`Local preflight failed. Critical paths are unreachable:\n${failures.join("\n")}`
449+
);
450+
}
451+
}
452+
453+
async function fetchText(url) {
454+
const response = await fetch(url);
455+
if (!response.ok) {
456+
throw new Error(`Failed to load preflight HTML (${response.status}): ${url}`);
457+
}
458+
459+
return response.text();
460+
}
461+
462+
function extractAssetPaths(html) {
463+
const matches = html.matchAll(/(?:src|href)=["']([^"']+)["']/g);
464+
const assetPaths = new Set();
465+
466+
for (const match of matches) {
467+
const value = match[1];
468+
if (!value.startsWith("/")) continue;
469+
if (!value.includes("/_next/static/")) continue;
470+
assetPaths.add(value);
471+
}
472+
473+
return assetPaths;
474+
}
475+
341476
function runCommand(command, commandArgs) {
342477
return new Promise((resolve, reject) => {
343478
const child = spawn(command, commandArgs, {
@@ -435,4 +570,16 @@ Options:
435570
--chromeFlags <flags> Extra Chrome flags.
436571
--help Show help.
437572
`);
438-
}
573+
}
574+
575+
const CONTENT_TYPES = {
576+
".css": "text/css; charset=utf-8",
577+
".html": "text/html; charset=utf-8",
578+
".ico": "image/x-icon",
579+
".js": "application/javascript; charset=utf-8",
580+
".json": "application/json; charset=utf-8",
581+
".svg": "image/svg+xml",
582+
".txt": "text/plain; charset=utf-8",
583+
".webp": "image/webp",
584+
".woff2": "font/woff2",
585+
};

0 commit comments

Comments
 (0)