@@ -128,7 +162,7 @@ function PerformanceRow({
{isFailed ? (
failed
@@ -137,13 +171,13 @@ function PerformanceRow({
)}
|
-
+
|
-
+
|
-
+
|
{result.lcpMs ? `${(result.lcpMs / 1000).toFixed(1)}s` : "-"}
@@ -158,10 +192,10 @@ function PerformanceRow({
{result.ttfbMs ? `${Math.round(result.ttfbMs)}ms` : "-"}
|
- {result.r2Key ? (
+ {result.r2Key && !isFailed ? (
View issues
diff --git a/src/client/features/audit/results/ResultsView.tsx b/src/client/features/audit/results/ResultsView.tsx
index 71b5822..f1340aa 100644
--- a/src/client/features/audit/results/ResultsView.tsx
+++ b/src/client/features/audit/results/ResultsView.tsx
@@ -7,6 +7,7 @@ import {
import type { AuditResultsData } from "@/client/features/audit/results/types";
import {
ExportDropdown,
+ isLighthouseFailure,
PagesTable,
PerformanceTable,
} from "@/client/features/audit/results/ResultsTables";
@@ -24,32 +25,32 @@ export function ResultsView({
tab: string;
setSearchParams: SearchSetter;
}) {
- const { audit, pages, psi } = data;
- const hasPerformanceTab = psi.length > 0;
+ const { audit, pages, lighthouse } = data;
+ const hasPerformanceTab = lighthouse.length > 0;
const activeTab = hasPerformanceTab ? tab : "pages";
- const stats = useResultStats(pages, psi);
+ const stats = useResultStats(pages, lighthouse);
return (
<>
{
if (activeTab === "performance") {
- exportPerformance(psi, pages, format);
+ exportPerformance(lighthouse, pages, format);
return;
}
exportPages(pages, format);
@@ -57,8 +58,13 @@ export function ResultsView({
/>
{activeTab === "pages" && }
- {activeTab === "performance" && psi.length > 0 && (
-
+ {activeTab === "performance" && lighthouse.length > 0 && (
+
)}
@@ -68,28 +74,34 @@ export function ResultsView({
function useResultStats(
pages: AuditResultsData["pages"],
- psi: AuditResultsData["psi"],
+ lighthouse: AuditResultsData["lighthouse"],
) {
const averageResponseMs = useMemo(() => {
if (pages.length === 0) return 0;
const total = pages.reduce(
- (sum, page) => sum + (page.responseTimeMs ?? 0),
+ (sum: number, page: AuditResultsData["pages"][number]) =>
+ sum + (page.responseTimeMs ?? 0),
0,
);
return Math.round(total / pages.length);
}, [pages]);
- const psiSummary = useMemo(() => {
- const failed = psi.filter((row) => !!row.errorMessage).length;
- const successful = psi.filter((row) => !row.errorMessage);
+ const lighthouseSummary = useMemo(() => {
+ const failed = lighthouse.filter(
+ (row: AuditResultsData["lighthouse"][number]) => isLighthouseFailure(row),
+ ).length;
+ const successful = lighthouse.filter(
+ (row: AuditResultsData["lighthouse"][number]) =>
+ !isLighthouseFailure(row),
+ );
const averageScore = (
key: "performanceScore" | "seoScore" | "accessibilityScore",
) => {
const values = successful
- .map((row) => row[key])
- .filter((value): value is number => value != null);
+ .map((row: AuditResultsData["lighthouse"][number]) => row[key])
+ .filter((value: number | null): value is number => value != null);
if (values.length === 0) return null;
- const total = values.reduce((sum, value) => sum + value, 0);
+ const total = values.reduce((sum: number, value) => sum + value, 0);
return Math.round(total / values.length);
};
@@ -99,21 +111,21 @@ function useResultStats(
avgSeo: averageScore("seoScore"),
avgAccessibility: averageScore("accessibilityScore"),
};
- }, [psi]);
+ }, [lighthouse]);
- return { averageResponseMs, psiSummary };
+ return { averageResponseMs, lighthouseSummary };
}
function ResultsHeader({
pageCount,
- psiCount,
+ lighthouseCount,
hasPerformanceTab,
activeTab,
setSearchParams,
onExport,
}: {
pageCount: number;
- psiCount: number;
+ lighthouseCount: number;
hasPerformanceTab: boolean;
activeTab: string;
setSearchParams: SearchSetter;
@@ -135,7 +147,7 @@ function ResultsHeader({
className={`tab ${activeTab === "performance" ? "tab-active" : ""}`}
onClick={() => setSearchParams({ tab: "performance" })}
>
- Performance ({psiCount})
+ Performance ({lighthouseCount})
) : (
@@ -150,15 +162,15 @@ function ResultsHeader({
function StatsGrid({
pagesCrawled,
totalPages,
- totalPsi,
+ totalLighthouse,
averageResponseMs,
- psiSummary,
+ lighthouseSummary,
}: {
pagesCrawled: number;
totalPages: number;
- totalPsi: number;
+ totalLighthouse: number;
averageResponseMs: number;
- psiSummary: {
+ lighthouseSummary: {
failed: number;
avgPerformance: number | null;
avgSeo: number | null;
@@ -169,37 +181,43 @@ function StatsGrid({
-
+
- {totalPsi > 0 && (
+ {totalLighthouse > 0 && (
<>
-
0 ? "text-error" : "text-success"}
+ label="Avg Lighthouse A11y"
+ value={
+ lighthouseSummary.avgAccessibility == null
+ ? "-"
+ : String(lighthouseSummary.avgAccessibility)
+ }
+ className={scoreClass(lighthouseSummary.avgAccessibility)}
+ />
+ 0 ? "text-error" : "text-success"
+ }
/>
>
)}
diff --git a/src/client/features/audit/results/export.ts b/src/client/features/audit/results/export.ts
index c69ca1f..e3c8f24 100644
--- a/src/client/features/audit/results/export.ts
+++ b/src/client/features/audit/results/export.ts
@@ -15,7 +15,7 @@ export function exportPages(
pages: AuditResultsData["pages"],
format: "csv" | "json",
) {
- const rows = pages.map((page) => ({
+ const rows = pages.map((page: AuditResultsData["pages"][number]) => ({
url: page.url,
statusCode: page.statusCode,
title: page.title ?? "",
@@ -45,7 +45,7 @@ export function exportPages(
"Missing Alt",
"Response Time (ms)",
];
- const lines = rows.map((row) => [
+ const lines = rows.map((row: (typeof rows)[number]) => [
row.url,
row.statusCode,
row.title,
@@ -60,24 +60,29 @@ export function exportPages(
}
export function exportPerformance(
- psi: AuditResultsData["psi"],
+ lighthouse: AuditResultsData["lighthouse"],
pages: AuditResultsData["pages"],
format: "csv" | "json",
) {
- const rows = psi.map((result) => {
- const page = pages.find((candidate) => candidate.id === result.pageId);
- return {
- url: page?.url ?? "",
- strategy: result.strategy,
- performance: result.performanceScore,
- accessibility: result.accessibilityScore,
- seo: result.seoScore,
- lcpMs: result.lcpMs,
- cls: result.cls,
- inpMs: result.inpMs,
- ttfbMs: result.ttfbMs,
- };
- });
+ const rows = lighthouse.map(
+ (result: AuditResultsData["lighthouse"][number]) => {
+ const page = pages.find(
+ (candidate: AuditResultsData["pages"][number]) =>
+ candidate.id === result.pageId,
+ );
+ return {
+ url: page?.url ?? "",
+ strategy: result.strategy,
+ performance: result.performanceScore,
+ accessibility: result.accessibilityScore,
+ seo: result.seoScore,
+ lcpMs: result.lcpMs,
+ cls: result.cls,
+ inpMs: result.inpMs,
+ ttfbMs: result.ttfbMs,
+ };
+ },
+ );
if (format === "json") {
downloadFile(
@@ -99,7 +104,7 @@ export function exportPerformance(
"INP (ms)",
"TTFB (ms)",
];
- const lines = rows.map((row) => [
+ const lines = rows.map((row: (typeof rows)[number]) => [
row.url,
row.strategy,
row.performance,
diff --git a/src/client/features/audit/shared.tsx b/src/client/features/audit/shared.tsx
index ec2fe00..1775c25 100644
--- a/src/client/features/audit/shared.tsx
+++ b/src/client/features/audit/shared.tsx
@@ -70,7 +70,7 @@ export function HttpStatusBadge({ code }: { code: number | null }) {
return {code};
}
-export function PsiScoreBadge({ score }: { score: number | null }) {
+export function LighthouseScoreBadge({ score }: { score: number | null }) {
if (score == null) {
return -;
}
diff --git a/src/client/features/lighthouse/issues/LighthouseIssueRow.tsx b/src/client/features/lighthouse/issues/LighthouseIssueRow.tsx
new file mode 100644
index 0000000..f7b093a
--- /dev/null
+++ b/src/client/features/lighthouse/issues/LighthouseIssueRow.tsx
@@ -0,0 +1,164 @@
+import { useState, type ReactNode } from "react";
+import {
+ ChevronRight,
+ ExternalLink,
+ FileWarning,
+ Info,
+ TriangleAlert,
+} from "lucide-react";
+import type { LighthouseIssue } from "./types";
+
+export function LighthouseIssueRow({ issue }: { issue: LighthouseIssue }) {
+ const [open, setOpen] = useState(false);
+ const hasDetails = !!(issue.description || issue.items.length > 0);
+
+ return (
+ <>
+ hasDetails && setOpen(!open)}
+ >
+ |
+ {hasDetails ? (
+
+ ) : null}
+ |
+
+
+ {severityIcon(issue.severity)}
+ {issue.severity}
+
+ |
+
+
+ {issue.title}
+ {issue.displayValue ? (
+
+ {issue.displayValue}
+
+ ) : null}
+
+ |
+
+ {issue.category}
+ |
+
+ {issue.impactMs != null || issue.impactBytes != null ? (
+
+ {issue.impactMs ? formatMs(issue.impactMs) : null}
+ {issue.impactMs && issue.impactBytes ? " / " : null}
+ {issue.impactBytes ? formatBytes(issue.impactBytes) : null}
+
+ ) : null}
+ |
+
+ {issue.score != null ? (
+
+ {issue.score}
+
+ ) : null}
+ |
+
+ {open ? (
+
+
+
+ {issue.description ? (
+
+ {renderInlineMarkdown(issue.description)}
+
+ ) : null}
+ {issue.items.length > 0 ? (
+
+
+ Affected items ({issue.items.length})
+
+
+ {issue.items.map((item, itemIndex) => (
+
+ {item}
+
+ ))}
+
+
+ ) : null}
+
+ |
+
+ ) : null}
+ >
+ );
+}
+
+function formatMs(ms: number) {
+ if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
+ return `${ms}ms`;
+}
+
+function formatBytes(bytes: number) {
+ if (bytes === 0) return "0 B";
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
+ return `${bytes} B`;
+}
+
+function renderInlineMarkdown(markdown: string): ReactNode {
+ const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
+ const nodes: ReactNode[] = [];
+ let cursor = 0;
+ let match = linkPattern.exec(markdown);
+
+ while (match) {
+ const [raw, label, href] = match;
+ const index = match.index;
+
+ if (index > cursor) {
+ nodes.push(markdown.slice(cursor, index));
+ }
+
+ nodes.push(
+
+ {label}
+
+ ,
+ );
+
+ cursor = index + raw.length;
+ match = linkPattern.exec(markdown);
+ }
+
+ if (cursor < markdown.length) {
+ nodes.push(markdown.slice(cursor));
+ }
+
+ return nodes.length ? nodes : markdown;
+}
+
+function severityBadgeClass(severity: "critical" | "warning" | "info") {
+ if (severity === "critical") {
+ return "border-error/30 bg-error/10 text-error/80";
+ }
+ if (severity === "warning") {
+ return "border-warning/35 bg-warning/10 text-warning/80";
+ }
+ return "border-info/30 bg-info/10 text-info/80";
+}
+
+function severityIcon(severity: "critical" | "warning" | "info") {
+ if (severity === "critical") return ;
+ if (severity === "warning") return ;
+ return ;
+}
diff --git a/src/client/features/psi/issues/PsiIssuesParts.tsx b/src/client/features/lighthouse/issues/LighthouseIssuesParts.tsx
similarity index 66%
rename from src/client/features/psi/issues/PsiIssuesParts.tsx
rename to src/client/features/lighthouse/issues/LighthouseIssuesParts.tsx
index 75b39fd..d8c0de3 100644
--- a/src/client/features/psi/issues/PsiIssuesParts.tsx
+++ b/src/client/features/lighthouse/issues/LighthouseIssuesParts.tsx
@@ -6,26 +6,33 @@ import {
Info,
TriangleAlert,
} from "lucide-react";
-import type { CategoryTab, ExportPayload, PsiIssue } from "./types";
-import {
- categoryLabel,
- renderInlineMarkdown,
- severityBadgeClass,
- severityIcon,
-} from "./utils";
+import type {
+ CategoryTab,
+ ExportPayload,
+ LighthouseIssue,
+ LighthouseMetrics,
+ LighthouseScores,
+} from "./types";
+import { LighthouseIssueRow } from "./LighthouseIssueRow";
+import { LighthouseIssuesSummary } from "./LighthouseIssuesSummary";
+import { categoryLabel } from "./utils";
import { categoryTabs } from "./types";
-export function PsiIssuesHeader({
+export function LighthouseIssuesHeader({
backLabel,
onBack,
scannedAt,
finalUrl,
+ scores,
+ metrics,
severityCounts,
}: {
backLabel: string;
onBack: () => void;
scannedAt?: string;
finalUrl?: string;
+ scores?: LighthouseScores | null;
+ metrics?: LighthouseMetrics | null;
severityCounts: { critical: number; warning: number; info: number };
}) {
return (
@@ -44,11 +51,12 @@ export function PsiIssuesHeader({
- PSI Issues
+ Lighthouse Issues
{finalUrl ?? "Loading URL..."}
+
@@ -69,7 +77,7 @@ export function PsiIssuesHeader({
);
}
-export function PsiIssuesToolbar({
+export function LighthouseIssuesToolbar({
category,
categoryCounts,
selectedCategoryLabel,
@@ -85,12 +93,12 @@ export function PsiIssuesToolbar({
categoryCounts: Record;
selectedCategoryLabel: string;
isBusy: boolean;
- visibleIssues: PsiIssue[];
- allIssues: PsiIssue[];
+ visibleIssues: LighthouseIssue[];
+ allIssues: LighthouseIssue[];
onCategoryChange: (next: CategoryTab) => void;
onCopy: (data: ExportPayload, toastMessage: string) => void;
onExport: (data: ExportPayload) => void;
- onExportCsv: (issues: PsiIssue[], variant: "all" | "current") => void;
+ onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
}) {
const exportCurrentCategory: ExportPayload =
category === "all" ? { mode: "issues" } : { mode: "category", category };
@@ -161,14 +169,14 @@ function ExportMenu({
onExportCsv,
visibleIssues,
}: {
- allIssues: PsiIssue[];
+ allIssues: LighthouseIssue[];
categoryLabelLower: string;
exportCurrentCategory: ExportPayload;
isBusy: boolean;
onCopy: (data: ExportPayload, toastMessage: string) => void;
onExport: (data: ExportPayload) => void;
- onExportCsv: (issues: PsiIssue[], variant: "all" | "current") => void;
- visibleIssues: PsiIssue[];
+ onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
+ visibleIssues: LighthouseIssue[];
}) {
return (
@@ -201,21 +209,23 @@ function ExportMenu({
@@ -234,12 +244,12 @@ function ExportMenu({
disabled={isBusy}
onClick={() => onExport({ mode: "issues" })}
>
- Download all issues
+ Download all actionable issues
@@ -258,7 +268,7 @@ function ExportMenu({
disabled={!allIssues.length}
onClick={() => onExportCsv(allIssues, "all")}
>
- Download all issues
+ Download all actionable issues
@@ -266,12 +276,14 @@ function ExportMenu({
);
}
-export function PsiIssueList({
+export function LighthouseIssueList({
issues,
isLoading,
+ emptyMessage,
}: {
- issues: PsiIssue[];
+ issues: LighthouseIssue[];
isLoading: boolean;
+ emptyMessage?: string;
}) {
if (isLoading) {
return Loading issues... ;
@@ -279,84 +291,40 @@ export function PsiIssueList({
if (!issues.length) {
return (
- No unresolved issues for this category.
+ {emptyMessage ?? "No actionable issues for this category."}
);
}
return (
-
- {issues.map((issue) => (
-
- ))}
-
- );
-}
-
-function PsiIssueCard({ issue }: { issue: PsiIssue }) {
- return (
-
-
-
-
- {issue.category}
-
- {severityIcon(issue.severity)}
- {issue.severity}
-
- {issue.score != null ? (
-
-
- Score {issue.score}
-
-
- ) : null}
-
-
- {issue.impactMs != null || issue.impactBytes != null ? (
-
- Impact {issue.impactMs ?? 0}ms / {issue.impactBytes ?? 0} bytes
-
- ) : null}
-
-
- {issue.title}
-
- {issue.displayValue ? (
- {issue.displayValue}
- ) : null}
-
- {issue.description ? (
-
- {renderInlineMarkdown(issue.description)}
-
- ) : null}
-
- {issue.items.length > 0 ? (
-
-
- Affected items ({issue.items.length})
-
-
- {issue.items.map((item) => (
-
- {item}
-
- ))}
-
-
- ) : null}
-
-
+
+
+
+
+
+
+
+
+
+
+
+ |
+ Severity |
+ Issue |
+ Category |
+
+ Impact
+ |
+ Score |
+
+
+
+ {issues.map((issue, issueIndex) => (
+
+ ))}
+
+
);
}
diff --git a/src/client/features/psi/issues/PsiIssuesScreen.tsx b/src/client/features/lighthouse/issues/LighthouseIssuesScreen.tsx
similarity index 61%
rename from src/client/features/psi/issues/PsiIssuesScreen.tsx
rename to src/client/features/lighthouse/issues/LighthouseIssuesScreen.tsx
index 416987c..1a050d9 100644
--- a/src/client/features/psi/issues/PsiIssuesScreen.tsx
+++ b/src/client/features/lighthouse/issues/LighthouseIssuesScreen.tsx
@@ -1,7 +1,11 @@
import { useMutation, useQuery } from "@tanstack/react-query";
+import { AlertCircle, TriangleAlert } from "lucide-react";
import { toast } from "sonner";
-import { exportAuditPsi, getAuditPsiIssues } from "@/serverFunctions/psi";
-import type { CategoryTab, ExportPayload, PsiIssue } from "./types";
+import {
+ exportAuditLighthouseIssues,
+ getAuditLighthouseIssues,
+} from "@/serverFunctions/lighthouse";
+import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types";
import {
categoryLabel,
categorySlug,
@@ -9,13 +13,13 @@ import {
issuesToCsv,
} from "./utils";
import {
- PsiIssueList,
- PsiIssuesHeader,
- PsiIssuesToolbar,
-} from "./PsiIssuesParts";
+ LighthouseIssueList,
+ LighthouseIssuesHeader,
+ LighthouseIssuesToolbar,
+} from "./LighthouseIssuesParts";
import { categoryTabs } from "./types";
-type PsiIssuesScreenProps = {
+type LighthouseIssuesScreenProps = {
projectId: string;
resultId: string;
category: CategoryTab;
@@ -24,26 +28,14 @@ type PsiIssuesScreenProps = {
onCategoryChange: (next: CategoryTab) => void;
};
-export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
+export function LighthouseIssuesScreen(props: LighthouseIssuesScreenProps) {
const { projectId, resultId, category, backLabel, onBack, onCategoryChange } =
props;
const issuesQuery = useQuery({
- queryKey: ["auditPsiIssues", projectId, resultId, category],
+ queryKey: ["auditLighthouseIssues", projectId, resultId],
queryFn: () =>
- getAuditPsiIssues({
- data: {
- projectId,
- resultId,
- category: category === "all" ? undefined : category,
- },
- }),
- });
-
- const summaryQuery = useQuery({
- queryKey: ["auditPsiIssuesSummary", projectId, resultId],
- queryFn: () =>
- getAuditPsiIssues({
+ getAuditLighthouseIssues({
data: {
projectId,
resultId,
@@ -52,8 +44,10 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
});
const exportMutation = useMutation({
- mutationFn: (data: ExportPayload) =>
- exportAuditPsi({
+ mutationFn: (
+ data: ExportPayload,
+ ): Promise<{ filename: string; content: string }> =>
+ exportAuditLighthouseIssues({
data: {
projectId,
resultId,
@@ -71,27 +65,56 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
selectedCategoryLabel,
severityCounts,
visibleIssues,
- } = usePsiIssuesActions({
+ } = useLighthouseIssuesActions({
category,
exportMutation,
- issues: (issuesQuery.data?.issues ?? []) as PsiIssue[],
- summaryIssues: summaryQuery.data?.issues,
+ allIssues: issuesQuery.data?.issues ?? [],
});
+ const issuesErrorMessage =
+ issuesQuery.error instanceof Error
+ ? issuesQuery.error.message
+ : "Failed to load Lighthouse issues.";
+ const showsLegacyPayloadNotice =
+ issuesQuery.data != null && !issuesQuery.data.hasIssueDetails;
+ const emptyMessage = showsLegacyPayloadNotice
+ ? "This audit was saved without issue-level Lighthouse details. Re-run the audit to populate this screen."
+ : undefined;
+
return (
-
-
+
+ {issuesErrorMessage}
+
+ ) : null}
+
+ {showsLegacyPayloadNotice ? (
+
+
+
+ This Lighthouse run was stored before issue details were
+ preserved. Re-run the audit to see category counts and issue
+ cards.
+
+
+ ) : null}
+
+
-
@@ -118,23 +142,23 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
);
}
-function usePsiIssuesActions({
+function useLighthouseIssuesActions({
+ allIssues,
category,
exportMutation,
- issues,
- summaryIssues,
}: {
+ allIssues: LighthouseIssue[];
category: CategoryTab;
exportMutation: {
mutateAsync: (
data: ExportPayload,
) => Promise<{ filename: string; content: string }>;
};
- issues: PsiIssue[];
- summaryIssues: PsiIssue[] | undefined;
}) {
- const visibleIssues = issues;
- const allIssues = summaryIssues ?? visibleIssues;
+ const visibleIssues =
+ category === "all"
+ ? allIssues
+ : allIssues.filter((issue) => issue.category === category);
const selectedCategoryLabel = categoryLabel(category);
const categoryCounts = getCategoryCounts(allIssues);
const severityCounts = getSeverityCounts(visibleIssues);
@@ -151,8 +175,11 @@ function usePsiIssuesActions({
}
};
- const runExportCsv = (rows: PsiIssue[], variant: "all" | "current") => {
- const filename = `psi-${variant}-${categorySlug(category)}-issues.csv`;
+ const runExportCsv = (
+ rows: LighthouseIssue[],
+ variant: "all" | "current",
+ ) => {
+ const filename = `lighthouse-${variant}-${categorySlug(category)}-issues.csv`;
downloadTextFile(filename, issuesToCsv(rows), "text/csv");
toast.success("CSV download started");
};
@@ -181,7 +208,9 @@ function usePsiIssuesActions({
};
}
-function getCategoryCounts(allIssues: PsiIssue[]): Record {
+function getCategoryCounts(
+ allIssues: LighthouseIssue[],
+): Record {
return categoryTabs.reduce>(
(acc, tab) => {
if (tab === "all") {
@@ -201,7 +230,7 @@ function getCategoryCounts(allIssues: PsiIssue[]): Record {
);
}
-function getSeverityCounts(issues: PsiIssue[]) {
+function getSeverityCounts(issues: LighthouseIssue[]) {
return {
critical: issues.filter((issue) => issue.severity === "critical").length,
warning: issues.filter((issue) => issue.severity === "warning").length,
diff --git a/src/client/features/lighthouse/issues/LighthouseIssuesSummary.tsx b/src/client/features/lighthouse/issues/LighthouseIssuesSummary.tsx
new file mode 100644
index 0000000..2a74222
--- /dev/null
+++ b/src/client/features/lighthouse/issues/LighthouseIssuesSummary.tsx
@@ -0,0 +1,121 @@
+import type { LighthouseMetrics, LighthouseScores } from "./types";
+
+export function LighthouseIssuesSummary({
+ scores,
+ metrics,
+}: {
+ scores?: LighthouseScores | null;
+ metrics?: LighthouseMetrics | null;
+}) {
+ const metricItems = getMetricItems(metrics);
+
+ if (!scores && metricItems.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+ {scores ? (
+
+
+
+
+
+
+ ) : null}
+ {metricItems.length > 0 ? (
+
+ {metricItems.map((metric) => (
+
+
+ {metric.label}
+
+
+ {metric.value}
+
+
+ ))}
+
+ ) : null}
+ >
+ );
+}
+
+function scoreColor(score: number | null) {
+ if (score == null) return "text-base-content/40";
+ if (score >= 90) return "text-success";
+ if (score >= 50) return "text-warning";
+ return "text-error";
+}
+
+function scoreStrokeColor(score: number | null) {
+ if (score == null) return "stroke-base-content/20";
+ if (score >= 90) return "stroke-success";
+ if (score >= 50) return "stroke-warning";
+ return "stroke-error";
+}
+
+function ScoreGauge({ label, score }: { label: string; score: number | null }) {
+ const displayScore = score ?? 0;
+ const radius = 28;
+ const circumference = 2 * Math.PI * radius;
+ const progress = (displayScore / 100) * circumference;
+
+ return (
+
+
+
+
+ {score ?? "-"}
+
+
+
+ {label}
+
+
+ );
+}
+
+function getMetricItems(metrics?: LighthouseMetrics | null) {
+ if (!metrics) return [];
+
+ return [
+ { label: "FCP", value: metrics.firstContentfulPaint.displayValue },
+ { label: "LCP", value: metrics.largestContentfulPaint.displayValue },
+ { label: "TBT", value: metrics.totalBlockingTime.displayValue },
+ { label: "SI", value: metrics.speedIndex.displayValue },
+ { label: "TTI", value: metrics.timeToInteractive.displayValue },
+ { label: "CLS", value: metrics.cumulativeLayoutShift.displayValue },
+ { label: "INP", value: metrics.interactionToNextPaint.displayValue },
+ { label: "TTFB", value: metrics.serverResponseTime.displayValue },
+ ].filter(
+ (metric): metric is { label: string; value: string } =>
+ metric.value != null,
+ );
+}
diff --git a/src/client/features/lighthouse/issues/types.ts b/src/client/features/lighthouse/issues/types.ts
new file mode 100644
index 0000000..8c224f6
--- /dev/null
+++ b/src/client/features/lighthouse/issues/types.ts
@@ -0,0 +1,26 @@
+import type { z } from "zod";
+import type { getAuditLighthouseIssues } from "@/serverFunctions/lighthouse";
+import {
+ LIGHTHOUSE_CATEGORY_TABS,
+ type LighthouseCategoryTab,
+} from "@/shared/lighthouse";
+import type { lighthouseAuditExportSchema } from "@/types/schemas/lighthouse";
+
+export const categoryTabs = LIGHTHOUSE_CATEGORY_TABS;
+
+export type CategoryTab = LighthouseCategoryTab;
+
+export type ExportPayload = Omit<
+ z.infer,
+ "projectId" | "resultId"
+>;
+
+type LighthouseIssuesResponse = Awaited<
+ ReturnType
+>;
+
+export type LighthouseIssue = LighthouseIssuesResponse["issues"][number];
+export type LighthouseScores = NonNullable;
+export type LighthouseMetrics = NonNullable<
+ LighthouseIssuesResponse["metrics"]
+>;
diff --git a/src/client/features/lighthouse/issues/utils.tsx b/src/client/features/lighthouse/issues/utils.tsx
new file mode 100644
index 0000000..5ca1fc2
--- /dev/null
+++ b/src/client/features/lighthouse/issues/utils.tsx
@@ -0,0 +1,53 @@
+import { buildCsv } from "@/client/lib/csv";
+import type { CategoryTab, LighthouseIssue } from "./types";
+
+export function categoryLabel(category: CategoryTab) {
+ if (category === "best-practices") return "Best practices";
+ if (category === "all") return "All";
+ return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
+}
+
+export function categorySlug(category: CategoryTab) {
+ return category === "all" ? "all" : category;
+}
+
+export function issuesToCsv(issues: LighthouseIssue[]) {
+ const headers = [
+ "Category",
+ "Severity",
+ "Score",
+ "Title",
+ "Display Value",
+ "Description",
+ "Impact (ms)",
+ "Impact (bytes)",
+ "Affected Items",
+ ];
+
+ const rows = issues.map((issue) => [
+ issue.category,
+ issue.severity,
+ issue.score ?? "",
+ issue.title,
+ issue.displayValue ?? "",
+ issue.description ?? "",
+ issue.impactMs ?? "",
+ issue.impactBytes ?? "",
+ issue.items.length,
+ ]);
+
+ return buildCsv(headers, rows);
+}
+
+export function downloadTextFile(
+ filename: string,
+ content: string,
+ mimeType: string,
+) {
+ const blob = new Blob([content], { type: mimeType });
+ const link = document.createElement("a");
+ link.href = URL.createObjectURL(blob);
+ link.download = filename;
+ link.click();
+ URL.revokeObjectURL(link.href);
+}
diff --git a/src/client/features/psi/issues/types.ts b/src/client/features/psi/issues/types.ts
deleted file mode 100644
index 7489032..0000000
--- a/src/client/features/psi/issues/types.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-export const categoryTabs = [
- "all",
- "performance",
- "accessibility",
- "best-practices",
- "seo",
-] as const;
-
-export type CategoryTab = (typeof categoryTabs)[number];
-export type IssueCategory = Exclude;
-
-export type ExportPayload = {
- mode: "full" | "issues" | "category";
- category?: IssueCategory;
-};
-
-export type PsiIssue = {
- auditKey: string;
- category: IssueCategory;
- severity: "critical" | "warning" | "info";
- score?: number | null;
- title: string;
- displayValue?: string | null;
- description?: string | null;
- impactMs?: number | null;
- impactBytes?: number | null;
- items: string[];
-};
diff --git a/src/client/features/psi/issues/utils.tsx b/src/client/features/psi/issues/utils.tsx
deleted file mode 100644
index 457e02f..0000000
--- a/src/client/features/psi/issues/utils.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import type { ReactNode } from "react";
-import { ExternalLink, FileWarning, Info, TriangleAlert } from "lucide-react";
-import { buildCsv } from "@/client/lib/csv";
-import type { CategoryTab, PsiIssue } from "./types";
-
-export function categoryLabel(category: CategoryTab) {
- if (category === "best-practices") return "Best practices";
- if (category === "all") return "All";
- return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
-}
-
-export function categorySlug(category: CategoryTab) {
- return category === "all" ? "all" : category;
-}
-
-export function issuesToCsv(issues: PsiIssue[]) {
- const headers = [
- "Category",
- "Severity",
- "Score",
- "Title",
- "Display Value",
- "Description",
- "Impact (ms)",
- "Impact (bytes)",
- "Affected Items",
- ];
-
- const rows = issues.map((issue) => [
- issue.category,
- issue.severity,
- issue.score ?? "",
- issue.title,
- issue.displayValue ?? "",
- issue.description ?? "",
- issue.impactMs ?? "",
- issue.impactBytes ?? "",
- issue.items.length,
- ]);
-
- return buildCsv(headers, rows);
-}
-
-export function renderInlineMarkdown(markdown: string): ReactNode {
- const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
- const nodes: ReactNode[] = [];
- let cursor = 0;
- let match = linkPattern.exec(markdown);
-
- while (match) {
- const [raw, label, href] = match;
- const index = match.index;
-
- if (index > cursor) {
- nodes.push(markdown.slice(cursor, index));
- }
-
- nodes.push(
-
- {label}
-
- ,
- );
-
- cursor = index + raw.length;
- match = linkPattern.exec(markdown);
- }
-
- if (cursor < markdown.length) {
- nodes.push(markdown.slice(cursor));
- }
-
- if (!nodes.length) {
- return markdown;
- }
-
- return nodes;
-}
-
-export function downloadTextFile(
- filename: string,
- content: string,
- mimeType: string,
-) {
- const blob = new Blob([content], { type: mimeType });
- const link = document.createElement("a");
- link.href = URL.createObjectURL(blob);
- link.download = filename;
- link.click();
- URL.revokeObjectURL(link.href);
-}
-
-export function severityBadgeClass(severity: "critical" | "warning" | "info") {
- if (severity === "critical") {
- return "border-error/30 bg-error/10 text-error/80";
- }
- if (severity === "warning") {
- return "border-warning/35 bg-warning/10 text-warning/80";
- }
- return "border-info/30 bg-info/10 text-info/80";
-}
-
-export function severityIcon(severity: "critical" | "warning" | "info") {
- if (severity === "critical") return ;
- if (severity === "warning") return ;
- return ;
-}
diff --git a/src/db/app.schema.ts b/src/db/app.schema.ts
index b6e1370..654074f 100644
--- a/src/db/app.schema.ts
+++ b/src/db/app.schema.ts
@@ -27,9 +27,6 @@ export const projects = sqliteTable("projects", {
.references(() => organization.id, { onDelete: "cascade" }),
name: text("name").notNull(),
domain: text("domain"),
- // PSI keys are used for Google API abuse-control, not direct billing.
- // We still keep handling explicit to make the tradeoff obvious.
- pagespeedApiKey: text("pagespeed_api_key"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
@@ -123,14 +120,14 @@ export const audits = sqliteTable(
.notNull()
.default("running"),
workflowInstanceId: text("workflow_instance_id"),
- // JSON config: { maxPages, psiStrategy, psiApiKey? }
+ // JSON config: { maxPages, lighthouseStrategy }
config: text("config").notNull().default("{}"),
// Progress & summary
pagesCrawled: integer("pages_crawled").notNull().default(0),
pagesTotal: integer("pages_total").notNull().default(0),
- psiTotal: integer("psi_total").notNull().default(0),
- psiCompleted: integer("psi_completed").notNull().default(0),
- psiFailed: integer("psi_failed").notNull().default(0),
+ lighthouseTotal: integer("lighthouse_total").notNull().default(0),
+ lighthouseCompleted: integer("lighthouse_completed").notNull().default(0),
+ lighthouseFailed: integer("lighthouse_failed").notNull().default(0),
currentPhase: text("current_phase").default("discovery"),
startedAt: text("started_at")
.notNull()
@@ -196,10 +193,9 @@ export const auditPages = sqliteTable(
(table) => [index("audit_pages_audit_id_idx").on(table.auditId)],
);
-// PSI summaries captured as part of a site audit run.
-// These belong to audit pages and are the only PSI result records we keep.
-export const auditPsiResults = sqliteTable(
- "audit_psi_results",
+// One row per Lighthouse test (mobile + desktop per page).
+export const auditLighthouseResults = sqliteTable(
+ "audit_lighthouse_results",
{
id: text("id").primaryKey(),
auditId: text("audit_id")
@@ -221,5 +217,5 @@ export const auditPsiResults = sqliteTable(
r2Key: text("r2_key"),
payloadSizeBytes: integer("payload_size_bytes"),
},
- (table) => [index("audit_psi_results_audit_id_idx").on(table.auditId)],
+ (table) => [index("audit_lighthouse_results_audit_id_idx").on(table.auditId)],
);
diff --git a/src/routes/_project/p/$projectId/audit/index.tsx b/src/routes/_project/p/$projectId/audit/index.tsx
index d479795..ca860f2 100644
--- a/src/routes/_project/p/$projectId/audit/index.tsx
+++ b/src/routes/_project/p/$projectId/audit/index.tsx
@@ -199,9 +199,9 @@ function ProgressCard({
status: {
pagesCrawled: number;
pagesTotal: number;
- psiTotal: number;
- psiCompleted: number;
- psiFailed: number;
+ lighthouseTotal: number;
+ lighthouseCompleted: number;
+ lighthouseFailed: number;
currentPhase: string | null;
};
}) {
@@ -209,21 +209,23 @@ function ProgressCard({
status.pagesTotal > 0
? Math.round((status.pagesCrawled / status.pagesTotal) * 100)
: 0;
- const psiDone = status.psiCompleted + status.psiFailed;
- const psiProgress =
- status.psiTotal > 0 ? Math.round((psiDone / status.psiTotal) * 100) : 0;
- const isPsiPhase = status.currentPhase === "psi";
+ const lighthouseDone = status.lighthouseCompleted + status.lighthouseFailed;
+ const lighthouseProgress =
+ status.lighthouseTotal > 0
+ ? Math.round((lighthouseDone / status.lighthouseTotal) * 100)
+ : 0;
+ const isLighthousePhase = status.currentPhase === "lighthouse";
const phaseLabel =
status.currentPhase === "discovery"
? "Discovery"
: status.currentPhase === "crawling"
? "Crawling"
- : status.currentPhase === "psi"
- ? "PSI"
+ : status.currentPhase === "lighthouse"
+ ? "Lighthouse"
: status.currentPhase === "finalizing"
? "Finalizing"
: (status.currentPhase ?? "Running");
- const progress = isPsiPhase ? psiProgress : crawlProgress;
+ const progress = isLighthousePhase ? lighthouseProgress : crawlProgress;
const crawlProgressQuery = useQuery({
queryKey: ["audit-crawl-progress", projectId, auditId],
@@ -240,7 +242,9 @@ function ProgressCard({
- {isPsiPhase ? "Running PSI checks" : "Crawling pages"}
+ {isLighthousePhase
+ ? "Running Lighthouse checks"
+ : "Crawling pages"}
{phaseLabel}
@@ -252,10 +256,12 @@ function ProgressCard({
/>
- {isPsiPhase ? (
+ {isLighthousePhase ? (
- {psiDone} / {status.psiTotal} checks
- {status.psiFailed > 0 ? ` (${status.psiFailed} failed)` : ""}
+ {lighthouseDone} / {status.lighthouseTotal} checks
+ {status.lighthouseFailed > 0
+ ? ` (${status.lighthouseFailed} failed)`
+ : ""}
) : (
diff --git a/src/routes/_project/p/$projectId/audit/issues/$resultId.tsx b/src/routes/_project/p/$projectId/audit/issues/$resultId.tsx
index 0978313..15ae4e1 100644
--- a/src/routes/_project/p/$projectId/audit/issues/$resultId.tsx
+++ b/src/routes/_project/p/$projectId/audit/issues/$resultId.tsx
@@ -1,21 +1,21 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
-import { PsiIssuesScreen } from "@/client/features/psi/issues/PsiIssuesScreen";
-import { psiIssuesSearchSchema } from "@/types/schemas/psi";
+import { LighthouseIssuesScreen } from "@/client/features/lighthouse/issues/LighthouseIssuesScreen";
+import { lighthouseIssuesSearchSchema } from "@/types/schemas/lighthouse";
export const Route = createFileRoute(
"/_project/p/$projectId/audit/issues/$resultId",
)({
- validateSearch: psiIssuesSearchSchema,
+ validateSearch: lighthouseIssuesSearchSchema,
component: AuditIssuesPage,
});
function AuditIssuesPage() {
const { projectId, resultId } = Route.useParams();
- const { category } = Route.useSearch();
+ const { auditId, category } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
return (
-
diff --git a/src/server/features/audit/repositories/AuditRepository.ts b/src/server/features/audit/repositories/AuditRepository.ts
index 6165724..0cf048f 100644
--- a/src/server/features/audit/repositories/AuditRepository.ts
+++ b/src/server/features/audit/repositories/AuditRepository.ts
@@ -1,13 +1,30 @@
/**
* Data access layer for site audit tables.
- * All D1 interactions for audits, audit_pages, and audit_psi_results.
+ * All D1 interactions for audits, audit_pages, and stored Lighthouse results.
*/
-import { db } from "@/db";
-import { audits, auditPages, auditPsiResults } from "@/db/schema";
import { and, desc, eq } from "drizzle-orm";
-import type { PsiResult, AuditConfig } from "@/server/lib/audit/types";
+import { db } from "@/db";
+import { audits, auditLighthouseResults, auditPages } from "@/db/schema";
+import type {
+ AuditConfig,
+ LighthouseResult,
+ StepPageResult,
+} from "@/server/lib/audit/types";
-// ─── Create ──────────────────────────────────────────────────────────────────
+const DB_BATCH_SIZE = 100;
+type BatchStatement = Parameters[0][number];
+
+async function executeInBatches(
+ items: T[],
+ buildStatement: (item: T) => BatchStatement,
+) {
+ for (let i = 0; i < items.length; i += DB_BATCH_SIZE) {
+ const chunk = items.slice(i, i + DB_BATCH_SIZE).map(buildStatement);
+ const [first, ...rest] = chunk;
+ if (!first) continue;
+ await db.batch([first, ...rest]);
+ }
+}
async function createAudit(data: {
id: string;
@@ -17,7 +34,7 @@ async function createAudit(data: {
workflowInstanceId: string;
config: AuditConfig;
pagesTotal: number;
- psiTotal: number;
+ lighthouseTotal: number;
}) {
await db.insert(audits).values({
id: data.id,
@@ -28,22 +45,20 @@ async function createAudit(data: {
config: JSON.stringify(data.config),
status: "running",
pagesTotal: data.pagesTotal,
- psiTotal: data.psiTotal,
+ lighthouseTotal: data.lighthouseTotal,
currentPhase: "discovery",
});
}
-// ─── Update ──────────────────────────────────────────────────────────────────
-
async function updateAuditProgress(
auditId: string,
workflowInstanceId: string,
data: {
pagesCrawled?: number;
pagesTotal?: number;
- psiTotal?: number;
- psiCompleted?: number;
- psiFailed?: number;
+ lighthouseTotal?: number;
+ lighthouseCompleted?: number;
+ lighthouseFailed?: number;
currentPhase?: string;
},
) {
@@ -110,132 +125,70 @@ async function getAuditForWorkflow(
});
}
-// ─── Batch write results (finalize step) ─────────────────────────────────────
-
-/**
- * Use db.batch() to send individual INSERT statements in a single round-trip.
- * D1's batch API supports up to 100 *statements* per call — each statement
- * has its own bind params, so there's no per-statement param limit issue.
- */
async function batchWriteResults(
auditId: string,
- pages: Array<{
- id: string;
- url: string;
- statusCode: number;
- redirectUrl: string | null;
- title: string;
- metaDescription: string;
- canonicalUrl: string | null;
- robotsMeta: string | null;
- ogTitle: string | null;
- ogDescription: string | null;
- ogImage: string | null;
- h1Count: number;
- h2Count: number;
- h3Count: number;
- h4Count: number;
- h5Count: number;
- h6Count: number;
- headingOrder: number[];
- wordCount: number;
- imagesTotal: number;
- imagesMissingAlt: number;
- images: Array<{ src: string | null; alt: string | null }>;
- internalLinks: string[];
- externalLinks: string[];
- hasStructuredData: boolean;
- hreflangTags: string[];
- isIndexable: boolean;
- responseTimeMs: number;
- }>,
- psiResults: PsiResult[],
+ pages: StepPageResult[],
+ lighthouseResults: LighthouseResult[],
) {
- const BATCH_SIZE = 100; // D1 max statements per batch() call
-
- // ── Pages ──────────────────────────────────────────────────────────
- const pageStatements = pages.map((p) =>
+ await executeInBatches(pages, (page) =>
db.insert(auditPages).values({
- id: p.id,
+ id: page.id,
auditId,
- url: p.url,
- statusCode: p.statusCode,
- redirectUrl: p.redirectUrl,
- // Metadata
- title: p.title,
- metaDescription: p.metaDescription,
- canonicalUrl: p.canonicalUrl,
- robotsMeta: p.robotsMeta,
- // Open Graph
- ogTitle: p.ogTitle,
- ogDescription: p.ogDescription,
- ogImage: p.ogImage,
- // Headings
- h1Count: p.h1Count,
- h2Count: p.h2Count,
- h3Count: p.h3Count,
- h4Count: p.h4Count,
- h5Count: p.h5Count,
- h6Count: p.h6Count,
- headingOrderJson: JSON.stringify(p.headingOrder),
- // Content
- wordCount: p.wordCount,
- // Images
- imagesTotal: p.imagesTotal,
- imagesMissingAlt: p.imagesMissingAlt,
- imagesJson: JSON.stringify(p.images),
- // Links
- internalLinkCount: p.internalLinks.length,
- externalLinkCount: p.externalLinks.length,
- // Structured data
- hasStructuredData: p.hasStructuredData,
- // Hreflang
- hreflangTagsJson: JSON.stringify(p.hreflangTags),
- // Indexability
- isIndexable: p.isIndexable,
- // Performance
- responseTimeMs: p.responseTimeMs,
+ url: page.url,
+ statusCode: page.statusCode,
+ redirectUrl: page.redirectUrl,
+ title: page.title,
+ metaDescription: page.metaDescription,
+ canonicalUrl: page.canonicalUrl,
+ robotsMeta: page.robotsMeta,
+ ogTitle: page.ogTitle,
+ ogDescription: page.ogDescription,
+ ogImage: page.ogImage,
+ h1Count: page.h1Count,
+ h2Count: page.h2Count,
+ h3Count: page.h3Count,
+ h4Count: page.h4Count,
+ h5Count: page.h5Count,
+ h6Count: page.h6Count,
+ headingOrderJson: JSON.stringify(page.headingOrder),
+ wordCount: page.wordCount,
+ imagesTotal: page.imagesTotal,
+ imagesMissingAlt: page.imagesMissingAlt,
+ imagesJson: JSON.stringify(page.images),
+ internalLinkCount: page.internalLinks.length,
+ externalLinkCount: page.externalLinks.length,
+ hasStructuredData: page.hasStructuredData,
+ hreflangTagsJson: JSON.stringify(page.hreflangTags),
+ isIndexable: page.isIndexable,
+ responseTimeMs: page.responseTimeMs,
}),
);
- for (let i = 0; i < pageStatements.length; i += BATCH_SIZE) {
- const chunk = pageStatements.slice(i, i + BATCH_SIZE);
- const [first, ...rest] = chunk;
- await db.batch([first, ...rest]);
+ if (lighthouseResults.length === 0) {
+ return;
}
- // ── PSI results ────────────────────────────────────────────────────
- if (psiResults.length > 0) {
- const psiStatements = psiResults.map((r) =>
- db.insert(auditPsiResults).values({
- id: crypto.randomUUID(),
- auditId,
- pageId: r.pageId,
- strategy: r.strategy,
- performanceScore: r.performanceScore,
- accessibilityScore: r.accessibilityScore,
- bestPracticesScore: r.bestPracticesScore,
- seoScore: r.seoScore,
- lcpMs: r.lcpMs,
- cls: r.cls,
- inpMs: r.inpMs,
- ttfbMs: r.ttfbMs,
- errorMessage: r.errorMessage ?? null,
- r2Key: r.r2Key ?? null,
- payloadSizeBytes: r.payloadSizeBytes ?? null,
- }),
- );
-
- for (let i = 0; i < psiStatements.length; i += BATCH_SIZE) {
- const chunk = psiStatements.slice(i, i + BATCH_SIZE);
- const [first, ...rest] = chunk;
- await db.batch([first, ...rest]);
- }
- }
+ await executeInBatches(lighthouseResults, (result) =>
+ db.insert(auditLighthouseResults).values({
+ id: crypto.randomUUID(),
+ auditId,
+ pageId: result.pageId,
+ strategy: result.strategy,
+ performanceScore: result.performanceScore,
+ accessibilityScore: result.accessibilityScore,
+ bestPracticesScore: result.bestPracticesScore,
+ seoScore: result.seoScore,
+ lcpMs: result.lcpMs,
+ cls: result.cls,
+ inpMs: result.inpMs,
+ ttfbMs: result.ttfbMs,
+ errorMessage: result.errorMessage ?? null,
+ r2Key: result.r2Key ?? null,
+ payloadSizeBytes: result.payloadSizeBytes ?? null,
+ }),
+ );
}
-// ─── Read ────────────────────────────────────────────────────────────────────
-
async function getAuditForProject(auditId: string, projectId: string) {
return db.query.audits.findFirst({
where: and(eq(audits.id, auditId), eq(audits.projectId, projectId)),
@@ -252,78 +205,80 @@ async function getAuditsByProject(projectId: string) {
return rows.map(({ audit }) => audit);
}
-async function getAuditResultsForProject(auditId: string, projectId: string) {
- const audit = await getAuditForProject(auditId, projectId);
- if (!audit) {
- return { audit: null, pages: [], psi: [] };
- }
-
- const [pages, psi] = await Promise.all([
- db.query.auditPages.findMany({
- where: eq(auditPages.auditId, auditId),
- }),
- db.query.auditPsiResults.findMany({
- where: eq(auditPsiResults.auditId, auditId),
- }),
- ]);
-
- return { audit, pages, psi };
-}
-
async function getAuditCapacityUsageForUser(userId: string) {
const rows = await db.query.audits.findMany({
where: eq(audits.startedByUserId, userId),
columns: {
pagesTotal: true,
- psiTotal: true,
+ lighthouseTotal: true,
},
});
- return rows.reduce((total, row) => total + row.pagesTotal + row.psiTotal, 0);
+ return rows.reduce(
+ (total, row) => total + row.pagesTotal + row.lighthouseTotal,
+ 0,
+ );
}
-async function getPsiResultById(input: {
- psiResultId: string;
- projectId: string;
-}) {
- const psi = await db.query.auditPsiResults.findFirst({
- where: eq(auditPsiResults.id, input.psiResultId),
- });
-
- if (!psi) return null;
-
- const parentAudit = await db.query.audits.findFirst({
- where: and(
- eq(audits.id, psi.auditId),
- eq(audits.projectId, input.projectId),
- ),
- });
-
- if (!parentAudit) {
- throw new Error("Audit not found");
+async function getAuditResultsForProject(auditId: string, projectId: string) {
+ const audit = await getAuditForProject(auditId, projectId);
+ if (!audit) {
+ return { audit: null, pages: [], lighthouse: [] };
}
- const page = await db.query.auditPages.findFirst({
- where: eq(auditPages.id, psi.pageId),
+ const [pages, lighthouse] = await Promise.all([
+ db.query.auditPages.findMany({
+ where: eq(auditPages.auditId, auditId),
+ }),
+ db.query.auditLighthouseResults.findMany({
+ where: eq(auditLighthouseResults.auditId, auditId),
+ }),
+ ]);
+
+ return { audit, pages, lighthouse };
+}
+
+async function getLighthouseResultById(input: {
+ lighthouseResultId: string;
+ projectId: string;
+}) {
+ const lighthouse = await db.query.auditLighthouseResults.findFirst({
+ where: eq(auditLighthouseResults.id, input.lighthouseResultId),
});
+ if (!lighthouse) {
+ return null;
+ }
+
+ const [parentAudit, page] = await Promise.all([
+ db.query.audits.findFirst({
+ where: and(
+ eq(audits.id, lighthouse.auditId),
+ eq(audits.projectId, input.projectId),
+ ),
+ }),
+ db.query.auditPages.findFirst({
+ where: eq(auditPages.id, lighthouse.pageId),
+ }),
+ ]);
+
+ if (!parentAudit) {
+ return null;
+ }
+
return {
- psi,
+ lighthouse,
page,
audit: parentAudit,
};
}
-// ─── Delete ──────────────────────────────────────────────────────────────────
-
async function deleteAuditForProject(auditId: string, projectId: string) {
await db
.delete(audits)
.where(and(eq(audits.id, auditId), eq(audits.projectId, projectId)));
}
-// ─── Export ──────────────────────────────────────────────────────────────────
-
export const AuditRepository = {
createAudit,
updateAuditProgress,
@@ -333,8 +288,8 @@ export const AuditRepository = {
batchWriteResults,
getAuditForProject,
getAuditsByProject,
- getAuditResultsForProject,
getAuditCapacityUsageForUser,
- getPsiResultById,
+ getAuditResultsForProject,
+ getLighthouseResultById,
deleteAuditForProject,
} as const;
diff --git a/src/server/features/audit/services/AuditService.ts b/src/server/features/audit/services/AuditService.ts
index 4a74f86..1e29d22 100644
--- a/src/server/features/audit/services/AuditService.ts
+++ b/src/server/features/audit/services/AuditService.ts
@@ -1,50 +1,33 @@
-/**
- * Business logic layer for site audits.
- * Orchestrates between the workflow trigger, repository, and data formatting.
- */
import { env } from "cloudflare:workers";
+import type { BillingCustomerContext } from "@/server/billing/subscription";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
-import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
-import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
-import { AppError } from "@/server/lib/errors";
-import type { AuditConfig, PsiStrategy } from "@/server/lib/audit/types";
-import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import {
+ MAX_USER_AUDIT_USAGE,
clampAuditMaxPages,
getEstimatedAuditCapacity,
- MAX_USER_AUDIT_USAGE,
} from "@/server/features/audit/services/audit-capacity";
-import { jsonCodec } from "@/shared/json";
-import { z } from "zod";
-
-const auditConfigSchema = z.object({
- maxPages: z.number().int().min(10).max(10_000),
- psiStrategy: z.enum(["auto", "all", "manual", "none"]),
- psiApiKey: z.string().optional(),
-});
-
-const auditConfigCodec = jsonCodec(auditConfigSchema);
-
-function parseAuditConfig(configRaw: string | null): AuditConfig | null {
- if (!configRaw) return null;
- const result = auditConfigCodec.safeParse(configRaw);
- return result.success ? result.data : null;
-}
+import { AppError } from "@/server/lib/errors";
+import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
+import {
+ parseAuditConfig,
+ type AuditConfig,
+ type LighthouseStrategy,
+} from "@/server/lib/audit/types";
+import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
async function startAudit(input: {
actorUserId: string;
+ billingCustomer: BillingCustomerContext;
projectId: string;
startUrl: string;
maxPages?: number;
- psiStrategy?: PsiStrategy;
- psiApiKey?: string;
+ lighthouseStrategy?: LighthouseStrategy;
}) {
const maxPages = clampAuditMaxPages(input.maxPages);
- const psiStrategy = input.psiStrategy ?? "auto";
-
+ const lighthouseStrategy = input.lighthouseStrategy ?? "auto";
const reservation = getEstimatedAuditCapacity({
maxPages,
- psiStrategy,
+ lighthouseStrategy,
});
const currentUsage = await AuditRepository.getAuditCapacityUsageForUser(
@@ -56,27 +39,7 @@ async function startAudit(input: {
}
const auditId = crypto.randomUUID();
-
- const shouldRunPsi = psiStrategy !== "none";
- let resolvedPsiApiKey = input.psiApiKey?.trim();
-
- if (shouldRunPsi && !resolvedPsiApiKey) {
- resolvedPsiApiKey =
- (await ProjectRepository.getProjectPsiApiKey(input.projectId)) ??
- undefined;
- }
-
- if (shouldRunPsi && !resolvedPsiApiKey) {
- throw new Error("PSI API key is not set for this project.");
- }
-
- const config: AuditConfig = {
- maxPages,
- psiStrategy,
- // PSI key is used for Google quota/abuse control (non-billing).
- psiApiKey: resolvedPsiApiKey,
- };
-
+ const config: AuditConfig = { maxPages, lighthouseStrategy };
const startUrl = await normalizeAndValidateStartUrl(input.startUrl);
await AuditRepository.createAudit({
@@ -87,15 +50,15 @@ async function startAudit(input: {
workflowInstanceId: auditId,
config,
pagesTotal: reservation.pagesTotal,
- psiTotal: reservation.psiTotal,
+ lighthouseTotal: reservation.lighthouseTotal,
});
- // Trigger the Cloudflare Workflow
try {
await env.SITE_AUDIT_WORKFLOW.create({
id: auditId,
params: {
auditId,
+ billingCustomer: input.billingCustomer,
projectId: input.projectId,
startUrl,
config,
@@ -108,6 +71,7 @@ async function startAudit(input: {
} catch {
// The workflow may never have been created, or may already be gone.
}
+
await AuditRepository.deleteAuditForProject(auditId, input.projectId);
throw error;
}
@@ -125,9 +89,9 @@ async function getStatus(auditId: string, projectId: string) {
status: audit.status,
pagesCrawled: audit.pagesCrawled,
pagesTotal: audit.pagesTotal,
- psiTotal: audit.psiTotal,
- psiCompleted: audit.psiCompleted,
- psiFailed: audit.psiFailed,
+ lighthouseTotal: audit.lighthouseTotal,
+ lighthouseCompleted: audit.lighthouseCompleted,
+ lighthouseFailed: audit.lighthouseFailed,
currentPhase: audit.currentPhase,
startedAt: audit.startedAt,
completedAt: audit.completedAt,
@@ -135,10 +99,8 @@ async function getStatus(auditId: string, projectId: string) {
}
async function getResults(auditId: string, projectId: string) {
- const { audit, pages, psi } = await AuditRepository.getAuditResultsForProject(
- auditId,
- projectId,
- );
+ const { audit, pages, lighthouse } =
+ await AuditRepository.getAuditResultsForProject(auditId, projectId);
if (!audit) throw new AppError("NOT_FOUND");
@@ -146,7 +108,6 @@ async function getResults(auditId: string, projectId: string) {
if (!parsedConfig) {
throw new AppError("INTERNAL_ERROR", "Invalid audit configuration");
}
- const { psiApiKey: _psiApiKey, ...safeConfig } = parsedConfig;
return {
audit: {
@@ -157,31 +118,31 @@ async function getResults(auditId: string, projectId: string) {
pagesTotal: audit.pagesTotal,
startedAt: audit.startedAt,
completedAt: audit.completedAt,
- config: safeConfig,
+ config: parsedConfig,
},
pages,
- psi,
+ lighthouse,
};
}
async function getHistory(projectId: string) {
const auditList = await AuditRepository.getAuditsByProject(projectId);
- const didRunPsi = (configRaw: string | null) => {
- const parsed = parseAuditConfig(configRaw);
- return parsed?.psiStrategy != null && parsed.psiStrategy !== "none";
- };
+ return auditList.map((audit) => {
+ const parsedConfig = parseAuditConfig(audit.config);
+ const ranLighthouse = parsedConfig?.lighthouseStrategy !== "none";
- return auditList.map((a) => ({
- id: a.id,
- startUrl: a.startUrl,
- status: a.status,
- pagesCrawled: a.pagesCrawled,
- pagesTotal: a.pagesTotal,
- ranPsi: didRunPsi(a.config),
- startedAt: a.startedAt,
- completedAt: a.completedAt,
- }));
+ return {
+ id: audit.id,
+ startUrl: audit.startUrl,
+ status: audit.status,
+ pagesCrawled: audit.pagesCrawled,
+ pagesTotal: audit.pagesTotal,
+ ranLighthouse,
+ startedAt: audit.startedAt,
+ completedAt: audit.completedAt,
+ };
+ });
}
async function getCrawlProgress(auditId: string, projectId: string) {
@@ -189,6 +150,7 @@ async function getCrawlProgress(auditId: string, projectId: string) {
if (!audit) {
throw new AppError("NOT_FOUND");
}
+
return AuditProgressKV.getCrawledUrls(auditId);
}
@@ -197,6 +159,7 @@ async function remove(auditId: string, projectId: string) {
if (!audit) {
throw new AppError("NOT_FOUND");
}
+
if (audit.status === "running") {
if (!audit.workflowInstanceId) {
throw new AppError(
@@ -215,6 +178,7 @@ async function remove(auditId: string, projectId: string) {
throw new AppError("CONFLICT", "Unable to stop the running audit.");
}
}
+
await AuditRepository.deleteAuditForProject(auditId, projectId);
}
diff --git a/src/server/features/audit/services/audit-capacity.test.ts b/src/server/features/audit/services/audit-capacity.test.ts
index a38a6e4..0739e56 100644
--- a/src/server/features/audit/services/audit-capacity.test.ts
+++ b/src/server/features/audit/services/audit-capacity.test.ts
@@ -13,41 +13,46 @@ describe("audit capacity helpers", () => {
expect(clampAuditMaxPages(20_000)).toBe(10_000);
});
- it("estimates capacity for each psi strategy", () => {
+ it("estimates capacity for each lighthouse strategy", () => {
expect(
- getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "none" }),
+ getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "none" }),
).toEqual({
pagesTotal: 100,
- psiTotal: 0,
+ lighthouseTotal: 0,
total: 100,
});
expect(
- getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "manual" }),
+ getEstimatedAuditCapacity({
+ maxPages: 100,
+ lighthouseStrategy: "manual",
+ }),
).toEqual({
pagesTotal: 100,
- psiTotal: 0,
+ lighthouseTotal: 0,
total: 100,
});
expect(
- getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "auto" }),
+ getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "auto" }),
).toEqual({
pagesTotal: 100,
- psiTotal: 20,
+ lighthouseTotal: 20,
total: 120,
});
expect(
- getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "all" }),
+ getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "all" }),
).toEqual({
pagesTotal: 100,
- psiTotal: 200,
+ lighthouseTotal: 200,
total: 300,
});
});
it("stays within the global capacity limit for the maximum auto audit", () => {
expect(
- getEstimatedAuditCapacity({ maxPages: 10_000, psiStrategy: "auto" })
- .total,
+ getEstimatedAuditCapacity({
+ maxPages: 10_000,
+ lighthouseStrategy: "auto",
+ }).total,
).toBeLessThan(MAX_USER_AUDIT_USAGE);
});
});
diff --git a/src/server/features/audit/services/audit-capacity.ts b/src/server/features/audit/services/audit-capacity.ts
index 97f6da5..9e5cf47 100644
--- a/src/server/features/audit/services/audit-capacity.ts
+++ b/src/server/features/audit/services/audit-capacity.ts
@@ -1,4 +1,4 @@
-import type { PsiStrategy } from "@/server/lib/audit/types";
+import type { LighthouseStrategy } from "@/server/lib/audit/types";
export const MAX_USER_AUDIT_USAGE = 100_000;
@@ -8,28 +8,28 @@ export function clampAuditMaxPages(maxPages?: number) {
export function getEstimatedAuditCapacity(input: {
maxPages?: number;
- psiStrategy?: PsiStrategy;
+ lighthouseStrategy?: LighthouseStrategy;
}) {
const pagesTotal = clampAuditMaxPages(input.maxPages);
- const psiStrategy = input.psiStrategy ?? "auto";
+ const lighthouseStrategy = input.lighthouseStrategy ?? "auto";
- let psiTotal = 0;
- switch (psiStrategy) {
+ let lighthouseChecks = 0;
+ switch (lighthouseStrategy) {
case "all":
- psiTotal = pagesTotal * 2;
+ lighthouseChecks = pagesTotal * 2;
break;
case "auto":
- psiTotal = 20;
+ lighthouseChecks = 20;
break;
case "manual":
case "none":
- psiTotal = 0;
+ lighthouseChecks = 0;
break;
}
return {
pagesTotal,
- psiTotal,
- total: pagesTotal + psiTotal,
+ lighthouseTotal: lighthouseChecks,
+ total: pagesTotal + lighthouseChecks,
};
}
diff --git a/src/server/features/lighthouse/services/lighthouse-export.test.ts b/src/server/features/lighthouse/services/lighthouse-export.test.ts
new file mode 100644
index 0000000..0140047
--- /dev/null
+++ b/src/server/features/lighthouse/services/lighthouse-export.test.ts
@@ -0,0 +1,170 @@
+import { z } from "zod";
+import { describe, expect, it } from "vitest";
+import { buildLighthouseExportFile } from "@/server/lib/lighthousePayload";
+
+const storedPayloadJson = JSON.stringify({
+ version: 2,
+ source: "dataforseo-lighthouse",
+ hasIssueDetails: true,
+ metadata: {
+ requestedUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh",
+ finalUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh",
+ strategy: "mobile",
+ fetchedAt: "2026-03-23T19:27:33.000Z",
+ lighthouseVersion: "12.2.0",
+ taskId: "task-1",
+ cost: 0.00425,
+ },
+ scores: {
+ performance: 89,
+ accessibility: 93,
+ "best-practices": 92,
+ seo: 91,
+ },
+ metrics: {
+ firstContentfulPaint: {
+ score: 47,
+ displayValue: "3.1 s",
+ numericValue: 3100,
+ },
+ largestContentfulPaint: {
+ score: 12,
+ displayValue: "6.4 s",
+ numericValue: 6400,
+ },
+ totalBlockingTime: {
+ score: 79,
+ displayValue: "290 ms",
+ numericValue: 290,
+ },
+ cumulativeLayoutShift: {
+ score: 92,
+ displayValue: "0.03",
+ numericValue: 0.03,
+ },
+ speedIndex: {
+ score: 86,
+ displayValue: "3.7 s",
+ numericValue: 3700,
+ },
+ timeToInteractive: {
+ score: 13,
+ displayValue: "12.8 s",
+ numericValue: 12800,
+ },
+ interactionToNextPaint: {
+ score: null,
+ displayValue: null,
+ numericValue: null,
+ },
+ serverResponseTime: {
+ score: 90,
+ displayValue: "52 ms",
+ numericValue: 52,
+ },
+ },
+ issues: [
+ {
+ category: "performance",
+ auditKey: "unused-javascript",
+ title: "Reduce unused JavaScript",
+ description: "Trim dead code.",
+ score: 50,
+ scoreDisplayMode: "metricSavings",
+ displayValue: "Potential savings of 227 KiB",
+ impactMs: 0,
+ impactBytes: 232886,
+ severity: "critical",
+ items: [],
+ },
+ {
+ category: "accessibility",
+ auditKey: "color-contrast",
+ title:
+ "Background and foreground colors do not have a sufficient contrast ratio.",
+ description: "Improve contrast.",
+ score: 0,
+ scoreDisplayMode: "binary",
+ displayValue: null,
+ impactMs: null,
+ impactBytes: null,
+ severity: "critical",
+ items: [],
+ },
+ ],
+});
+
+const issuesExportSchema = z.object({
+ resultId: z.string(),
+ category: z.string(),
+ issues: z.array(
+ z.object({
+ auditKey: z.string(),
+ category: z.string(),
+ }),
+ ),
+});
+
+describe("buildLighthouseExportFile", () => {
+ it("exports the stored payload unchanged for full mode", () => {
+ const exported = buildLighthouseExportFile({
+ idField: "resultId",
+ idValue: "result-1",
+ finalUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh",
+ strategy: "mobile",
+ createdAt: "2026-03-23T19:27:33.000Z",
+ payloadJson: storedPayloadJson,
+ mode: "full",
+ });
+
+ expect(exported.filename).toContain("-payload.json");
+ expect(exported.content).toBe(storedPayloadJson);
+ });
+
+ it("exports only actionable issues for issues mode", () => {
+ const exported = buildLighthouseExportFile({
+ idField: "resultId",
+ idValue: "result-1",
+ finalUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh",
+ strategy: "mobile",
+ createdAt: "2026-03-23T19:27:33.000Z",
+ payloadJson: storedPayloadJson,
+ mode: "issues",
+ });
+
+ const content = issuesExportSchema.parse(JSON.parse(exported.content));
+
+ expect(exported.filename).toContain("-issues.json");
+ expect(content.resultId).toBe("result-1");
+ expect(content.category).toBe("all");
+ expect(content.issues.map((issue) => issue.auditKey)).toEqual([
+ "unused-javascript",
+ "color-contrast",
+ ]);
+ expect(exported.content).not.toContain("timeToInteractive");
+ });
+
+ it("exports only the selected category for category mode", () => {
+ const exported = buildLighthouseExportFile({
+ idField: "resultId",
+ idValue: "result-1",
+ finalUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh",
+ strategy: "mobile",
+ createdAt: "2026-03-23T19:27:33.000Z",
+ payloadJson: storedPayloadJson,
+ mode: "category",
+ category: "accessibility",
+ });
+
+ const content = issuesExportSchema.parse(JSON.parse(exported.content));
+
+ expect(exported.filename).toContain("-accessibility-issues.json");
+ expect(content.category).toBe("accessibility");
+ expect(content.issues).toEqual([
+ expect.objectContaining({
+ auditKey: "color-contrast",
+ category: "accessibility",
+ }),
+ ]);
+ });
+});
diff --git a/src/server/features/projects/repositories/ProjectRepository.ts b/src/server/features/projects/repositories/ProjectRepository.ts
index 8a61294..629e9c2 100644
--- a/src/server/features/projects/repositories/ProjectRepository.ts
+++ b/src/server/features/projects/repositories/ProjectRepository.ts
@@ -28,28 +28,6 @@ async function getProjectById(projectId: string) {
});
}
-async function getProjectPsiApiKey(projectId: string) {
- const project = await db.query.projects.findFirst({
- where: eq(projects.id, projectId),
- columns: { pagespeedApiKey: true },
- });
- return project?.pagespeedApiKey ?? null;
-}
-
-async function setProjectPsiApiKey(projectId: string, apiKey: string) {
- await db
- .update(projects)
- .set({ pagespeedApiKey: apiKey })
- .where(eq(projects.id, projectId));
-}
-
-async function clearProjectPsiApiKey(projectId: string) {
- await db
- .update(projects)
- .set({ pagespeedApiKey: null })
- .where(eq(projects.id, projectId));
-}
-
async function createProject(
organizationId: string,
name: string,
@@ -85,9 +63,6 @@ export const ProjectRepository = {
listProjects,
getProjectForOrganization,
getProjectById,
- getProjectPsiApiKey,
- setProjectPsiApiKey,
- clearProjectPsiApiKey,
createProject,
deleteProject,
} as const;
diff --git a/src/server/features/psi/services/PsiAuditService.ts b/src/server/features/psi/services/PsiAuditService.ts
deleted file mode 100644
index f8dc50d..0000000
--- a/src/server/features/psi/services/PsiAuditService.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import { AppError } from "@/server/lib/errors";
-import { getJsonFromR2 } from "@/server/lib/r2";
-import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
-import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
-import {
- PsiIssuesService,
- type PsiIssueCategory,
-} from "@/server/features/psi/services/PsiIssuesService";
-import { buildPsiExportFile } from "@/server/features/psi/services/psi-export";
-
-type PsiStrategy = "mobile" | "desktop";
-type ExportMode = "full" | "issues" | "category";
-
-type AuditPsiTarget = {
- id: string;
- strategy: PsiStrategy;
- finalUrl: string;
- createdAt: string;
- r2Key: string | null;
-};
-
-async function getAuditPsiTarget(input: {
- projectId: string;
- resultId: string;
-}): Promise {
- const site = await AuditRepository.getPsiResultById({
- psiResultId: input.resultId,
- projectId: input.projectId,
- });
-
- if (!site) {
- throw new AppError("NOT_FOUND");
- }
-
- return {
- id: site.psi.id,
- strategy: site.psi.strategy,
- finalUrl: site.page?.url ?? "",
- createdAt: site.audit.startedAt,
- r2Key: site.psi.r2Key,
- };
-}
-
-async function getProjectPsiApiKey(input: { projectId: string }) {
- const apiKey = await ProjectRepository.getProjectPsiApiKey(input.projectId);
- return { apiKey };
-}
-
-async function saveProjectPsiApiKey(input: {
- projectId: string;
- apiKey: string;
-}) {
- await ProjectRepository.setProjectPsiApiKey(
- input.projectId,
- input.apiKey.trim(),
- );
- return { success: true };
-}
-
-async function clearProjectPsiApiKey(input: { projectId: string }) {
- await ProjectRepository.clearProjectPsiApiKey(input.projectId);
- return { success: true };
-}
-
-async function getAuditPsiIssues(input: {
- projectId: string;
- resultId: string;
- category?: PsiIssueCategory;
-}) {
- const target = await getAuditPsiTarget(input);
- if (!target.r2Key) {
- throw new AppError("NOT_FOUND");
- }
-
- const payloadJson = await getJsonFromR2(target.r2Key);
- const issues = PsiIssuesService.parseIssues(payloadJson, input.category);
-
- return {
- id: target.id,
- finalUrl: target.finalUrl,
- strategy: target.strategy,
- createdAt: target.createdAt,
- issues,
- };
-}
-
-async function exportAuditPsi(input: {
- projectId: string;
- resultId: string;
- mode: ExportMode;
- category?: PsiIssueCategory;
-}) {
- const target = await getAuditPsiTarget(input);
- if (!target.r2Key) {
- throw new AppError("NOT_FOUND");
- }
-
- const payloadJson = await getJsonFromR2(target.r2Key);
-
- return buildPsiExportFile({
- idField: "resultId",
- idValue: target.id,
- finalUrl: target.finalUrl,
- strategy: target.strategy,
- createdAt: target.createdAt,
- payloadJson,
- mode: input.mode,
- category: input.mode === "category" ? input.category : undefined,
- });
-}
-
-export const PsiAuditService = {
- getProjectPsiApiKey,
- saveProjectPsiApiKey,
- clearProjectPsiApiKey,
- getAuditPsiIssues,
- exportAuditPsi,
-} as const;
diff --git a/src/server/features/psi/services/PsiIssuesService.ts b/src/server/features/psi/services/PsiIssuesService.ts
deleted file mode 100644
index c706ca6..0000000
--- a/src/server/features/psi/services/PsiIssuesService.ts
+++ /dev/null
@@ -1,230 +0,0 @@
-import { sortBy } from "remeda";
-import { z } from "zod";
-import { jsonCodec } from "@/shared/json";
-
-const PSI_CATEGORIES = [
- "performance",
- "accessibility",
- "best-practices",
- "seo",
-] as const;
-
-export type PsiIssueCategory = (typeof PSI_CATEGORIES)[number];
-
-type PsiIssue = {
- category: PsiIssueCategory;
- auditKey: string;
- title: string;
- description: string;
- score: number | null;
- scoreDisplayMode: string | null;
- displayValue: string | null;
- impactMs: number | null;
- impactBytes: number | null;
- severity: "critical" | "warning" | "info";
- items: string[];
-};
-
-type LighthouseAudit = {
- title?: string;
- description?: string;
- score?: number | null;
- scoreDisplayMode?: string;
- displayValue?: string;
- details?: {
- overallSavingsMs?: number;
- overallSavingsBytes?: number;
- items?: Array>;
- };
-};
-
-type LighthouseCategory = {
- auditRefs?: Array<{
- id?: string;
- }>;
-};
-
-const lighthouseAuditSchema = z.object({
- title: z.string().optional(),
- description: z.string().optional(),
- score: z.number().nullable().optional(),
- scoreDisplayMode: z.string().optional(),
- displayValue: z.string().optional(),
- details: z
- .object({
- overallSavingsMs: z.number().optional(),
- overallSavingsBytes: z.number().optional(),
- items: z.array(z.record(z.string(), z.unknown())).optional(),
- })
- .optional(),
-});
-
-const lighthouseCategorySchema = z.object({
- auditRefs: z
- .array(
- z.object({
- id: z.string().optional(),
- }),
- )
- .optional(),
-});
-
-const psiPayloadSchema = z.object({
- lighthouseResult: z
- .object({
- audits: z
- .record(z.string(), lighthouseAuditSchema)
- .optional()
- .default({}),
- categories: z
- .record(z.string(), lighthouseCategorySchema)
- .optional()
- .default({}),
- })
- .optional()
- .default({
- audits: {},
- categories: {},
- }),
-});
-
-const psiPayloadCodec = jsonCodec(psiPayloadSchema);
-
-function normalizeScore(score: number | null | undefined): number | null {
- if (score == null || Number.isNaN(score)) return null;
- return Math.round(score * 100);
-}
-
-function compactItem(item: Record): string {
- const preferredKeys = [
- "url",
- "source",
- "nodeLabel",
- "snippet",
- "totalBytes",
- "wastedBytes",
- "wastedMs",
- "label",
- "value",
- ];
-
- const output: Record = {};
- for (const key of preferredKeys) {
- if (item[key] != null) {
- output[key] = item[key];
- }
- }
-
- if (Object.keys(output).length === 0) {
- for (const [key, value] of Object.entries(item).slice(0, 6)) {
- output[key] = value;
- }
- }
-
- return JSON.stringify(output);
-}
-
-function getSeverity(input: {
- score: number | null;
- impactMs: number | null;
- impactBytes: number | null;
-}): "critical" | "warning" | "info" {
- if ((input.impactMs ?? 0) >= 300 || (input.impactBytes ?? 0) >= 150_000) {
- return "critical";
- }
-
- if (input.score != null && input.score < 50) {
- return "critical";
- }
-
- if ((input.impactMs ?? 0) >= 100 || (input.impactBytes ?? 0) >= 50_000) {
- return "warning";
- }
-
- if (input.score != null && input.score < 90) {
- return "warning";
- }
-
- return "info";
-}
-
-function parseIssues(
- payloadJson: string,
- categoryFilter?: PsiIssueCategory,
-): PsiIssue[] {
- const parsedPayload = psiPayloadCodec.safeParse(payloadJson);
- if (!parsedPayload.success) {
- throw new Error("Invalid Lighthouse payload JSON");
- }
-
- const audits: Record =
- parsedPayload.data.lighthouseResult.audits;
- const categories: Record =
- parsedPayload.data.lighthouseResult.categories;
-
- const issues: PsiIssue[] = [];
-
- for (const category of PSI_CATEGORIES) {
- if (categoryFilter && category !== categoryFilter) continue;
-
- const refs = categories[category]?.auditRefs ?? [];
- for (const ref of refs) {
- const auditKey = ref.id;
- if (!auditKey) continue;
-
- const audit = audits[auditKey];
- if (!audit) continue;
-
- const score = normalizeScore(audit.score);
- const displayMode = audit.scoreDisplayMode ?? null;
-
- const isPass =
- (score != null && score >= 90) ||
- displayMode === "notApplicable" ||
- displayMode === "informative" ||
- displayMode === "manual";
-
- if (isPass) continue;
-
- const impactMs =
- typeof audit.details?.overallSavingsMs === "number"
- ? audit.details.overallSavingsMs
- : null;
- const impactBytes =
- typeof audit.details?.overallSavingsBytes === "number"
- ? audit.details.overallSavingsBytes
- : null;
-
- const items = Array.isArray(audit.details?.items)
- ? audit.details.items.slice(0, 10).map(compactItem)
- : [];
-
- issues.push({
- category,
- auditKey,
- title: audit.title ?? auditKey,
- description: audit.description ?? "",
- score,
- scoreDisplayMode: displayMode,
- displayValue: audit.displayValue ?? null,
- impactMs,
- impactBytes,
- severity: getSeverity({ score, impactMs, impactBytes }),
- items,
- });
- }
- }
-
- return sortBy(
- issues,
- [
- (issue) => (issue.impactMs ?? 0) * 1000 + (issue.impactBytes ?? 0),
- "desc",
- ],
- [(issue) => issue.score ?? 100, "asc"],
- );
-}
-
-export const PsiIssuesService = {
- parseIssues,
-} as const;
diff --git a/src/server/features/psi/services/psi-export.ts b/src/server/features/psi/services/psi-export.ts
deleted file mode 100644
index e19297e..0000000
--- a/src/server/features/psi/services/psi-export.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import {
- PsiIssuesService,
- type PsiIssueCategory,
-} from "@/server/features/psi/services/PsiIssuesService";
-
-type PsiStrategy = "mobile" | "desktop";
-type ExportMode = "full" | "issues" | "category";
-
-export function buildPsiExportFile(input: {
- idField: "auditId" | "resultId";
- idValue: string;
- finalUrl: string;
- strategy: PsiStrategy;
- createdAt: string;
- payloadJson: string;
- mode: ExportMode;
- category?: PsiIssueCategory;
-}) {
- const safeDate = input.createdAt.replace(/[:.]/g, "-");
- const baseName = `psi-${input.strategy}-${safeDate}`;
-
- if (input.mode === "full") {
- return {
- filename: `${baseName}-full.json`,
- content: input.payloadJson,
- };
- }
-
- const issues = PsiIssuesService.parseIssues(
- input.payloadJson,
- input.category,
- );
-
- return {
- filename:
- input.mode === "category" && input.category
- ? `${baseName}-${input.category}-issues.json`
- : `${baseName}-issues.json`,
- content: JSON.stringify(
- {
- [input.idField]: input.idValue,
- finalUrl: input.finalUrl,
- strategy: input.strategy,
- createdAt: input.createdAt,
- category: input.category ?? "all",
- issues,
- },
- null,
- 2,
- ),
- };
-}
diff --git a/src/server/lib/audit/lighthouse.ts b/src/server/lib/audit/lighthouse.ts
new file mode 100644
index 0000000..1db8975
--- /dev/null
+++ b/src/server/lib/audit/lighthouse.ts
@@ -0,0 +1,163 @@
+import { detectUrlTemplate } from "./url-utils";
+import type { BillingCustomerContext } from "@/server/billing/subscription";
+import { createDataforseoClient } from "@/server/lib/dataforseoClient";
+import type { LighthouseResult, LighthouseStrategy } from "./types";
+import { putTextToR2 } from "@/server/lib/r2";
+
+interface LighthouseSamplePage {
+ url: string;
+ statusCode: number;
+}
+
+type LighthouseFetchResult = {
+ result: LighthouseResult;
+ payloadJson: string | null;
+};
+
+async function fetchLighthouseResult(
+ url: string,
+ pageId: string,
+ strategy: "mobile" | "desktop",
+ billingCustomer: BillingCustomerContext,
+): Promise {
+ let lastError: Error | null = null;
+ const dataforseo = createDataforseoClient(billingCustomer);
+
+ for (let attempt = 0; attempt < 3; attempt++) {
+ try {
+ if (attempt > 0) {
+ // Exponential backoff: 2s, 4s
+ await new Promise((resolve) =>
+ setTimeout(resolve, 2000 * Math.pow(2, attempt - 1)),
+ );
+ }
+
+ const data = await dataforseo.lighthouse.live({ url, strategy });
+
+ return {
+ result: {
+ url,
+ pageId,
+ strategy,
+ performanceScore: data.scores.performance,
+ accessibilityScore: data.scores.accessibility,
+ bestPracticesScore: data.scores["best-practices"],
+ seoScore: data.scores.seo,
+ lcpMs: data.metrics.largestContentfulPaint.numericValue,
+ cls: data.metrics.cumulativeLayoutShift.numericValue,
+ inpMs: data.metrics.interactionToNextPaint.numericValue,
+ ttfbMs: data.metrics.serverResponseTime.numericValue,
+ },
+ payloadJson: JSON.stringify(data),
+ };
+ } catch (error) {
+ lastError = error instanceof Error ? error : new Error(String(error));
+ console.warn(
+ `Lighthouse attempt ${attempt + 1} failed for ${url}:`,
+ lastError.message,
+ );
+ }
+ }
+
+ // All retries exhausted — return null scores
+ console.error(
+ `Lighthouse failed after 3 attempts for ${url}:`,
+ lastError?.message,
+ );
+ return {
+ result: {
+ url,
+ pageId,
+ strategy,
+ performanceScore: null,
+ accessibilityScore: null,
+ bestPracticesScore: null,
+ seoScore: null,
+ lcpMs: null,
+ cls: null,
+ inpMs: null,
+ ttfbMs: null,
+ errorMessage: lastError?.message ?? "Lighthouse request failed",
+ },
+ payloadJson: null,
+ };
+}
+
+export async function fetchAndStoreLighthouseResult(input: {
+ url: string;
+ pageId: string;
+ strategy: "mobile" | "desktop";
+ billingCustomer: BillingCustomerContext;
+ projectId: string;
+ auditId: string;
+}): Promise {
+ const fetched = await fetchLighthouseResult(
+ input.url,
+ input.pageId,
+ input.strategy,
+ input.billingCustomer,
+ );
+
+ if (!fetched.payloadJson) {
+ return fetched.result;
+ }
+
+ const key = `site-audit/${input.projectId}/${input.auditId}/${input.pageId}-${input.strategy}.json`;
+ const uploaded = await putTextToR2(key, fetched.payloadJson);
+
+ return {
+ ...fetched.result,
+ r2Key: uploaded.key,
+ payloadSizeBytes: uploaded.sizeBytes,
+ };
+}
+
+/**
+ * Select which pages to run Lighthouse on, based on the chosen strategy.
+ */
+export function selectLighthouseSample(
+ pages: LighthouseSamplePage[],
+ startUrl: string,
+ strategy: LighthouseStrategy,
+): string[] {
+ if (strategy === "none") return [];
+
+ // Only consider pages that loaded successfully
+ const validPages = pages.filter(
+ (p) => p.statusCode >= 200 && p.statusCode < 300,
+ );
+
+ if (strategy === "all") {
+ return validPages.map((p) => p.url);
+ }
+
+ if (strategy === "manual") {
+ // manual = user picks after crawl; for now return empty
+ return [];
+ }
+
+ // strategy === "auto": homepage + 1 per URL pattern, capped at 10
+ const selected = new Set();
+
+ // Always include the start URL / homepage
+ const startPage = validPages.find((p) => p.url === startUrl);
+ if (startPage) selected.add(startPage.url);
+
+ // Group by URL template pattern
+ const templateGroups = new Map();
+ for (const page of validPages) {
+ if (selected.has(page.url)) continue;
+ const template = detectUrlTemplate(new URL(page.url).pathname);
+ if (!templateGroups.has(template)) {
+ templateGroups.set(template, page);
+ }
+ }
+
+ // Add one page per template group
+ for (const [, page] of templateGroups) {
+ if (selected.size >= 10) break;
+ selected.add(page.url);
+ }
+
+ return Array.from(selected);
+}
diff --git a/src/server/lib/audit/psi.ts b/src/server/lib/audit/psi.ts
deleted file mode 100644
index 3bef211..0000000
--- a/src/server/lib/audit/psi.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-/**
- * Google PageSpeed Insights (PSI) API client and sampling logic.
- */
-import { detectUrlTemplate } from "./url-utils";
-import type { PsiResult, PsiStrategy } from "./types";
-
-interface PsiSamplePage {
- url: string;
- statusCode: number;
-}
-
-const PSI_API_URL =
- "https://www.googleapis.com/pagespeedonline/v5/runPagespeed";
-
-/**
- * Fetch PageSpeed Insights results for a single URL.
- * Retries up to 3 times with exponential backoff.
- */
-export async function fetchPsiResult(
- url: string,
- pageId: string,
- strategy: "mobile" | "desktop",
- apiKey: string,
-): Promise {
- // Build URL with multiple category params (PSI API allows repeated 'category')
- const apiUrl = `${PSI_API_URL}?url=${encodeURIComponent(url)}&strategy=${strategy}&key=${encodeURIComponent(apiKey)}&category=performance&category=accessibility&category=best-practices&category=seo`;
-
- let lastError: Error | null = null;
-
- for (let attempt = 0; attempt < 3; attempt++) {
- try {
- if (attempt > 0) {
- // Exponential backoff: 2s, 4s
- await new Promise((resolve) =>
- setTimeout(resolve, 2000 * Math.pow(2, attempt - 1)),
- );
- }
-
- const response = await fetch(apiUrl, {
- signal: AbortSignal.timeout(60_000), // PSI can be slow
- });
-
- if (!response.ok) {
- const text = await response.text();
- throw new Error(`PSI API ${response.status}: ${text.slice(0, 200)}`);
- }
-
- const data: PsiApiResponse = await response.json();
-
- return parsePsiResponse(data, url, pageId, strategy);
- } catch (error) {
- lastError = error instanceof Error ? error : new Error(String(error));
- console.warn(
- `PSI attempt ${attempt + 1} failed for ${url}:`,
- lastError.message,
- );
- }
- }
-
- // All retries exhausted — return null scores
- console.error(`PSI failed after 3 attempts for ${url}:`, lastError?.message);
- return {
- url,
- pageId,
- strategy,
- performanceScore: null,
- accessibilityScore: null,
- bestPracticesScore: null,
- seoScore: null,
- lcpMs: null,
- cls: null,
- inpMs: null,
- ttfbMs: null,
- errorMessage: lastError?.message ?? "PSI request failed",
- };
-}
-
-/**
- * Select which pages to run PSI on, based on the chosen strategy.
- */
-export function selectPsiSample(
- pages: PsiSamplePage[],
- startUrl: string,
- strategy: PsiStrategy,
-): string[] {
- if (strategy === "none") return [];
-
- // Only consider pages that loaded successfully
- const validPages = pages.filter(
- (p) => p.statusCode >= 200 && p.statusCode < 300,
- );
-
- if (strategy === "all") {
- return validPages.map((p) => p.url);
- }
-
- if (strategy === "manual") {
- // manual = user picks after crawl; for now return empty
- return [];
- }
-
- // strategy === "auto": homepage + 1 per URL pattern, capped at 10
- const selected = new Set();
-
- // Always include the start URL / homepage
- const startPage = validPages.find((p) => p.url === startUrl);
- if (startPage) selected.add(startPage.url);
-
- // Group by URL template pattern
- const templateGroups = new Map();
- for (const page of validPages) {
- if (selected.has(page.url)) continue;
- const template = detectUrlTemplate(new URL(page.url).pathname);
- if (!templateGroups.has(template)) {
- templateGroups.set(template, page);
- }
- }
-
- // Add one page per template group
- for (const [, page] of templateGroups) {
- if (selected.size >= 10) break;
- selected.add(page.url);
- }
-
- return Array.from(selected);
-}
-
-// ─── PSI API Response Types ──────────────────────────────────────────────────
-
-interface PsiApiResponse {
- lighthouseResult?: {
- categories?: {
- performance?: { score?: number | null };
- accessibility?: { score?: number | null };
- "best-practices"?: { score?: number | null };
- seo?: { score?: number | null };
- };
- audits?: {
- "largest-contentful-paint"?: { numericValue?: number };
- "cumulative-layout-shift"?: { numericValue?: number };
- "interaction-to-next-paint"?: { numericValue?: number };
- "server-response-time"?: { numericValue?: number };
- };
- };
-}
-
-function parsePsiResponse(
- data: PsiApiResponse,
- url: string,
- pageId: string,
- strategy: "mobile" | "desktop",
-): PsiResult {
- const categories = data.lighthouseResult?.categories;
- const audits = data.lighthouseResult?.audits;
-
- return {
- url,
- pageId,
- strategy,
- performanceScore: scoreToPercent(categories?.performance?.score),
- accessibilityScore: scoreToPercent(categories?.accessibility?.score),
- bestPracticesScore: scoreToPercent(categories?.["best-practices"]?.score),
- seoScore: scoreToPercent(categories?.seo?.score),
- lcpMs: audits?.["largest-contentful-paint"]?.numericValue ?? null,
- cls: audits?.["cumulative-layout-shift"]?.numericValue ?? null,
- inpMs: audits?.["interaction-to-next-paint"]?.numericValue ?? null,
- ttfbMs: audits?.["server-response-time"]?.numericValue ?? null,
- rawPayloadJson: JSON.stringify(data),
- };
-}
-
-/** PSI scores come as 0-1 floats; convert to 0-100 integers. */
-function scoreToPercent(score: number | null | undefined): number | null {
- if (score == null) return null;
- return Math.round(score * 100);
-}
diff --git a/src/server/lib/audit/types.ts b/src/server/lib/audit/types.ts
index eb3470a..4aab81d 100644
--- a/src/server/lib/audit/types.ts
+++ b/src/server/lib/audit/types.ts
@@ -2,12 +2,27 @@
* Shared types for the site audit system.
*/
-export type PsiStrategy = "auto" | "all" | "manual" | "none";
+import { z } from "zod";
+import { jsonCodec } from "@/shared/json";
+
+export type LighthouseStrategy = "auto" | "all" | "manual" | "none";
export interface AuditConfig {
maxPages: number;
- psiStrategy: PsiStrategy;
- psiApiKey?: string;
+ lighthouseStrategy: LighthouseStrategy;
+}
+
+const auditConfigSchema = z.object({
+ maxPages: z.number().int().min(10).max(10_000),
+ lighthouseStrategy: z.enum(["auto", "all", "manual", "none"]),
+});
+
+const auditConfigCodec = jsonCodec(auditConfigSchema);
+
+export function parseAuditConfig(configRaw: string | null): AuditConfig | null {
+ if (!configRaw) return null;
+ const result = auditConfigCodec.safeParse(configRaw);
+ return result.success ? result.data : null;
}
/** Data extracted from a single page via cheerio. */
@@ -47,8 +62,8 @@ export interface PageAnalysis {
hreflangTags: string[];
}
-/** PSI result for a single URL+strategy. */
-export interface PsiResult {
+/** Lighthouse result for a single URL+strategy. */
+export interface LighthouseResult {
url: string;
pageId: string;
strategy: "mobile" | "desktop";
@@ -63,5 +78,35 @@ export interface PsiResult {
errorMessage?: string | null;
r2Key?: string | null;
payloadSizeBytes?: number | null;
- rawPayloadJson?: string | null;
+}
+
+export interface StepPageResult {
+ id: string;
+ url: string;
+ statusCode: number;
+ redirectUrl: string | null;
+ title: string;
+ metaDescription: string;
+ canonicalUrl: string | null;
+ robotsMeta: string | null;
+ ogTitle: string | null;
+ ogDescription: string | null;
+ ogImage: string | null;
+ h1Count: number;
+ h2Count: number;
+ h3Count: number;
+ h4Count: number;
+ h5Count: number;
+ h6Count: number;
+ headingOrder: number[];
+ wordCount: number;
+ imagesTotal: number;
+ imagesMissingAlt: number;
+ images: Array<{ src: string | null; alt: string | null }>;
+ internalLinks: string[];
+ externalLinks: string[];
+ hasStructuredData: boolean;
+ hreflangTags: string[];
+ isIndexable: boolean;
+ responseTimeMs: number;
}
diff --git a/src/server/lib/dataforseoClient.ts b/src/server/lib/dataforseoClient.ts
index f6ef037..7d9c823 100644
--- a/src/server/lib/dataforseoClient.ts
+++ b/src/server/lib/dataforseoClient.ts
@@ -18,6 +18,9 @@ import {
type LabsKeywordDataItem,
type SerpLiveItem,
} from "@/server/lib/dataforseo";
+import { fetchDataforseoLighthouseResultRaw } from "@/server/lib/dataforseoLighthouse";
+import type { LighthouseStrategy } from "@/server/lib/dataforseoLighthousePayload";
+import type { StoredLighthousePayload } from "@/server/lib/lighthouseStoredPayload";
import {
fetchBacklinksRowsRaw,
fetchBacklinksSummaryRaw,
@@ -166,6 +169,13 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
);
},
},
+ lighthouse: {
+ live(input: { url: string; strategy: LighthouseStrategy }) {
+ return meterDataforseoCall(customer, () =>
+ fetchDataforseoLighthouseResultRaw(input),
+ );
+ },
+ },
} as const;
}
diff --git a/src/server/lib/dataforseoLighthouse.ts b/src/server/lib/dataforseoLighthouse.ts
new file mode 100644
index 0000000..185c203
--- /dev/null
+++ b/src/server/lib/dataforseoLighthouse.ts
@@ -0,0 +1,60 @@
+import { env } from "cloudflare:workers";
+import {
+ parseDataforseoLighthousePayload,
+ requestCategories,
+ type LighthouseStrategy,
+} from "@/server/lib/dataforseoLighthousePayload";
+import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost";
+import type { StoredLighthousePayload } from "@/server/lib/lighthouseStoredPayload";
+
+const DATAFORSEO_LIGHTHOUSE_ENDPOINT =
+ "https://api.dataforseo.com/v3/on_page/lighthouse/live/json";
+
+export async function fetchDataforseoLighthouseResultRaw(input: {
+ url: string;
+ strategy: LighthouseStrategy;
+}): Promise> {
+ const response = await fetch(DATAFORSEO_LIGHTHOUSE_ENDPOINT, {
+ method: "POST",
+ headers: {
+ Authorization: `Basic ${env.DATAFORSEO_API_KEY?.trim() ?? ""}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify([
+ {
+ url: input.url,
+ for_mobile: input.strategy === "mobile",
+ categories: requestCategories,
+ },
+ ]),
+ signal: AbortSignal.timeout(60_000),
+ });
+
+ const rawText = await response.text();
+
+ if (!response.ok) {
+ throw new Error(
+ `DataForSEO Lighthouse request failed (${response.status}): ${rawText}`,
+ );
+ }
+
+ let payload: unknown;
+ try {
+ payload = JSON.parse(rawText);
+ } catch {
+ throw new Error(
+ `DataForSEO Lighthouse returned non-JSON content (content-type: ${response.headers.get("content-type") ?? "unknown"}): ${rawText}`,
+ );
+ }
+
+ const data = parseDataforseoLighthousePayload(payload, input);
+
+ return {
+ data,
+ billing: {
+ path: ["v3", "on_page", "lighthouse", "live", "json"],
+ costUsd: data.metadata.cost ?? 0,
+ resultCount: 1,
+ },
+ };
+}
diff --git a/src/server/lib/dataforseoLighthousePayload.test.ts b/src/server/lib/dataforseoLighthousePayload.test.ts
new file mode 100644
index 0000000..bbe0f1c
--- /dev/null
+++ b/src/server/lib/dataforseoLighthousePayload.test.ts
@@ -0,0 +1,250 @@
+import { describe, expect, it } from "vitest";
+import { parseDataforseoLighthousePayload } from "@/server/lib/dataforseoLighthousePayload";
+import { readStoredLighthousePayload } from "@/server/lib/lighthousePayload";
+
+describe("parseDataforseoLighthousePayload", () => {
+ it("stores only issue-level lighthouse data and key metadata", () => {
+ const parsed = parseDataforseoLighthousePayload(
+ {
+ status_code: 20000,
+ status_message: "Ok.",
+ tasks: [
+ {
+ id: "task-1",
+ status_code: 20000,
+ status_message: "Ok.",
+ cost: 0.00425,
+ result: [
+ {
+ requestedUrl: "https://everyapp.dev/",
+ finalUrl: "https://everyapp.dev/",
+ lighthouseVersion: "12.2.0",
+ categories: {
+ performance: {
+ score: 0.54,
+ auditRefs: [{ id: "unused-javascript" }],
+ },
+ accessibility: {
+ score: 0.93,
+ auditRefs: [{ id: "accesskeys" }],
+ },
+ "best-practices": { score: 0.79, auditRefs: [] },
+ seo: { score: 0.92, auditRefs: [] },
+ },
+ audits: {
+ "unused-javascript": {
+ title: "Reduce unused JavaScript",
+ description: "Trim dead code.",
+ score: 0,
+ scoreDisplayMode: "metricSavings",
+ displayValue: "Potential savings of 188 KiB",
+ numericValue: 193002,
+ details: {
+ overallSavingsMs: 1270,
+ overallSavingsBytes: 193002,
+ items: [
+ {
+ url: "https://cdn.example.com/app.js",
+ wastedBytes: 193002,
+ },
+ ],
+ },
+ },
+ accesskeys: {
+ title: "`[accesskey]` values are unique",
+ description: "Access keys should not conflict.",
+ score: null,
+ scoreDisplayMode: "error",
+ },
+ interactive: {
+ title: "Time to Interactive",
+ description: "Time until the page becomes interactive.",
+ score: 0.13,
+ scoreDisplayMode: "numeric",
+ displayValue: "12.8 s",
+ numericValue: 12800,
+ },
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ url: "https://everyapp.dev/",
+ strategy: "mobile",
+ },
+ );
+
+ const { report } = readStoredLighthousePayload(JSON.stringify(parsed));
+
+ expect(parsed.metrics.timeToInteractive.displayValue).toBe("12.8 s");
+ expect(parsed).toMatchObject({
+ version: 2,
+ source: "dataforseo-lighthouse",
+ hasIssueDetails: true,
+ metadata: {
+ requestedUrl: "https://everyapp.dev/",
+ finalUrl: "https://everyapp.dev/",
+ strategy: "mobile",
+ lighthouseVersion: "12.2.0",
+ taskId: "task-1",
+ cost: 0.00425,
+ },
+ scores: {
+ performance: 54,
+ accessibility: 93,
+ "best-practices": 79,
+ seo: 92,
+ },
+ metrics: {
+ timeToInteractive: {
+ score: 13,
+ displayValue: "12.8 s",
+ numericValue: 12800,
+ },
+ },
+ });
+ expect(parsed.issues).toHaveLength(1);
+ expect(parsed.issues).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ auditKey: "interactive" }),
+ ]),
+ );
+ expect(parsed).not.toHaveProperty("lighthouseResult");
+
+ expect(report.hasIssueDetails).toBe(true);
+ expect(report.issues).toEqual([
+ expect.objectContaining({
+ auditKey: "unused-javascript",
+ category: "performance",
+ impactMs: 1270,
+ impactBytes: 193002,
+ title: "Reduce unused JavaScript",
+ }),
+ ]);
+ });
+
+ it("throws when the lighthouse response has no category scores", () => {
+ expect(() =>
+ parseDataforseoLighthousePayload(
+ {
+ status_code: 20000,
+ status_message: "Ok.",
+ tasks: [
+ {
+ id: "task-1",
+ status_code: 20000,
+ status_message: "Ok.",
+ cost: 0.00425,
+ result: [
+ {
+ requestedUrl:
+ "https://everyapp.dev/blog/category/cyber-security",
+ finalUrl:
+ "https://everyapp.dev/blog/category/cyber-security/",
+ lighthouseVersion: "12.2.0",
+ categories: {
+ performance: { score: null, auditRefs: [] },
+ accessibility: { score: null, auditRefs: [] },
+ "best-practices": { score: null, auditRefs: [] },
+ seo: { score: null, auditRefs: [] },
+ },
+ audits: {},
+ },
+ ],
+ },
+ ],
+ },
+ {
+ url: "https://everyapp.dev/blog/category/cyber-security",
+ strategy: "desktop",
+ },
+ ),
+ ).toThrow("DataForSEO Lighthouse returned no category scores");
+ });
+
+ it("throws when DataForSEO returns a non-success task status", () => {
+ expect(() =>
+ parseDataforseoLighthousePayload(
+ {
+ status_code: 20000,
+ status_message: "Ok.",
+ tasks: [
+ {
+ id: "task-1",
+ status_code: 40501,
+ status_message: "Insufficient credits",
+ result: [],
+ },
+ ],
+ },
+ {
+ url: "https://everyapp.dev/",
+ strategy: "mobile",
+ },
+ ),
+ ).toThrow("Insufficient credits");
+ });
+
+ it("includes schema details when the payload shape is invalid", () => {
+ expect(() =>
+ parseDataforseoLighthousePayload(null, {
+ url: "https://everyapp.dev/",
+ strategy: "mobile",
+ }),
+ ).toThrow("");
+ });
+
+ it("accepts audits whose details.items is an object", () => {
+ expect(() =>
+ parseDataforseoLighthousePayload(
+ {
+ status_code: 20000,
+ status_message: "Ok.",
+ tasks: [
+ {
+ id: "task-1",
+ status_code: 20000,
+ status_message: "Ok.",
+ cost: 0.00425,
+ result: [
+ {
+ requestedUrl: "https://everyapp.dev/",
+ finalUrl: "https://everyapp.dev/",
+ lighthouseVersion: "12.2.0",
+ categories: {
+ performance: {
+ score: 0.54,
+ auditRefs: [{ id: "document-latency-insight" }],
+ },
+ accessibility: { score: 0.93, auditRefs: [] },
+ "best-practices": { score: 0.79, auditRefs: [] },
+ seo: { score: 0.92, auditRefs: [] },
+ },
+ audits: {
+ "document-latency-insight": {
+ title: "Document request latency",
+ description: "Latency insight.",
+ score: 0,
+ scoreDisplayMode: "informative",
+ details: {
+ items: {
+ latencyMs: 120,
+ },
+ },
+ },
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ url: "https://everyapp.dev/",
+ strategy: "mobile",
+ },
+ ),
+ ).not.toThrow();
+ });
+});
diff --git a/src/server/lib/dataforseoLighthousePayload.ts b/src/server/lib/dataforseoLighthousePayload.ts
new file mode 100644
index 0000000..edacd1e
--- /dev/null
+++ b/src/server/lib/dataforseoLighthousePayload.ts
@@ -0,0 +1,172 @@
+import { z } from "zod";
+import {
+ buildStoredLighthouseIssues,
+ buildStoredLighthouseMetrics,
+ type RawLighthouseAudit,
+ type RawLighthouseCategory,
+ scoreToPercent,
+ type StoredLighthousePayload,
+} from "@/server/lib/lighthouseStoredPayload";
+
+export const requestCategories = [
+ "performance",
+ "accessibility",
+ "best_practices",
+ "seo",
+] as const;
+
+export type LighthouseStrategy = "mobile" | "desktop";
+
+const lighthouseAuditItemsSchema = z
+ .union([
+ z.array(z.record(z.string(), z.unknown())),
+ z.record(z.string(), z.unknown()),
+ ])
+ .transform((items) => (Array.isArray(items) ? items : [items]));
+
+const lighthouseAuditSchema = z
+ .object({
+ score: z.number().nullable().optional(),
+ displayValue: z.string().optional(),
+ numericValue: z.number().optional(),
+ title: z.string().optional(),
+ description: z.string().optional(),
+ scoreDisplayMode: z.string().optional(),
+ details: z
+ .object({
+ overallSavingsMs: z.number().optional(),
+ overallSavingsBytes: z.number().optional(),
+ items: lighthouseAuditItemsSchema.optional(),
+ })
+ .passthrough()
+ .optional(),
+ })
+ .passthrough();
+
+const lighthouseCategorySchema = z
+ .object({
+ score: z.number().nullable().optional(),
+ auditRefs: z
+ .array(
+ z
+ .object({
+ id: z.string().optional(),
+ })
+ .passthrough(),
+ )
+ .optional(),
+ })
+ .passthrough();
+
+const lighthouseResponseSchema = z
+ .object({
+ requestedUrl: z.string().optional(),
+ finalUrl: z.string().optional(),
+ lighthouseVersion: z.string().optional(),
+ categories: z
+ .record(z.string(), lighthouseCategorySchema)
+ .optional()
+ .default({}),
+ audits: z.record(z.string(), lighthouseAuditSchema).optional().default({}),
+ })
+ .passthrough();
+
+const dataforseoTaskSchema = z
+ .object({
+ id: z.string().optional(),
+ cost: z.number().optional(),
+ status_code: z.number().optional(),
+ status_message: z.string().optional(),
+ result: z.array(lighthouseResponseSchema).optional(),
+ })
+ .passthrough();
+
+const dataforseoLighthouseResponseSchema = z
+ .object({
+ status_code: z.number().optional(),
+ status_message: z.string().optional(),
+ tasks: z.array(dataforseoTaskSchema).optional(),
+ })
+ .passthrough();
+
+function summarizeZodIssues(error: z.ZodError, maxIssues = 3): string {
+ return error.issues
+ .slice(0, maxIssues)
+ .map((issue) => {
+ const path = issue.path.length > 0 ? issue.path.join(".") : "";
+ return `${path}: ${issue.message}`;
+ })
+ .join("; ");
+}
+
+export function parseDataforseoLighthousePayload(
+ payload: unknown,
+ input: { url: string; strategy: LighthouseStrategy },
+): StoredLighthousePayload {
+ const parsed = dataforseoLighthouseResponseSchema.safeParse(payload);
+ if (!parsed.success) {
+ throw new Error(
+ `DataForSEO Lighthouse returned an invalid response: ${summarizeZodIssues(parsed.error)}`,
+ );
+ }
+
+ if (parsed.data.status_code !== 20000) {
+ throw new Error(
+ parsed.data.status_message ?? "DataForSEO Lighthouse request failed",
+ );
+ }
+
+ const task = parsed.data.tasks?.[0];
+ if (!task) {
+ throw new Error("DataForSEO Lighthouse response missing task");
+ }
+
+ if (task.status_code !== 20000) {
+ throw new Error(task.status_message ?? "DataForSEO Lighthouse task failed");
+ }
+
+ const result = task.result?.[0];
+ if (!result) {
+ throw new Error("DataForSEO Lighthouse response missing result");
+ }
+
+ const fetchedAt = new Date().toISOString();
+ const categories: Record =
+ result.categories ?? {};
+ const audits: Record = result.audits ?? {};
+ const issueReport = buildStoredLighthouseIssues({ audits, categories });
+ const metrics = buildStoredLighthouseMetrics({ audits });
+ const storedPayload: StoredLighthousePayload = {
+ version: 2,
+ source: "dataforseo-lighthouse",
+ hasIssueDetails: issueReport.hasIssueDetails,
+ metadata: {
+ requestedUrl: result.requestedUrl ?? input.url,
+ finalUrl: result.finalUrl ?? input.url,
+ strategy: input.strategy,
+ fetchedAt,
+ lighthouseVersion: result.lighthouseVersion ?? null,
+ taskId: task.id ?? null,
+ cost: task.cost ?? null,
+ },
+ scores: {
+ performance: scoreToPercent(categories.performance?.score),
+ accessibility: scoreToPercent(categories.accessibility?.score),
+ "best-practices": scoreToPercent(categories["best-practices"]?.score),
+ seo: scoreToPercent(categories.seo?.score),
+ },
+ metrics,
+ issues: issueReport.issues,
+ };
+
+ const allScoresMissing = Object.values(storedPayload.scores).every(
+ (score) => score == null,
+ );
+ if (allScoresMissing) {
+ throw new Error(
+ `DataForSEO Lighthouse returned no category scores for ${storedPayload.metadata.finalUrl}`,
+ );
+ }
+
+ return storedPayload;
+}
diff --git a/src/server/lib/lighthousePayload.ts b/src/server/lib/lighthousePayload.ts
new file mode 100644
index 0000000..9e6d757
--- /dev/null
+++ b/src/server/lib/lighthousePayload.ts
@@ -0,0 +1,123 @@
+import { sortBy } from "remeda";
+import type { LighthouseCategory } from "@/shared/lighthouse";
+import { jsonCodec } from "@/shared/json";
+import {
+ storedLighthousePayloadSchema,
+ type StoredLighthouseIssue,
+ type StoredLighthousePayload,
+} from "@/server/lib/lighthouseStoredPayload";
+
+const storedPayloadCodec = jsonCodec(storedLighthousePayloadSchema);
+
+type ExportMode = "full" | "issues" | "category";
+
+type LighthouseIssueReport = {
+ issues: StoredLighthouseIssue[];
+ hasIssueDetails: boolean;
+};
+
+function sortIssues(issues: StoredLighthouseIssue[]) {
+ return sortBy(
+ issues,
+ [
+ (issue) => (issue.impactMs ?? 0) * 1000 + (issue.impactBytes ?? 0),
+ "desc",
+ ],
+ [(issue) => issue.score ?? 100, "asc"],
+ );
+}
+
+function parseStoredLighthousePayload(
+ payloadJson: string,
+): StoredLighthousePayload | null {
+ const storedPayload = storedPayloadCodec.safeParse(payloadJson);
+ if (storedPayload.success) {
+ return storedPayload.data;
+ }
+
+ try {
+ JSON.parse(payloadJson);
+ } catch {
+ throw new Error("Invalid Lighthouse payload JSON");
+ }
+
+ return null;
+}
+
+function buildLighthouseIssueReport(
+ storedPayload: StoredLighthousePayload | null,
+ categoryFilter?: LighthouseCategory,
+): LighthouseIssueReport {
+ if (!storedPayload) {
+ return {
+ hasIssueDetails: false,
+ issues: [],
+ };
+ }
+
+ const filteredIssues = categoryFilter
+ ? storedPayload.issues.filter((issue) => issue.category === categoryFilter)
+ : storedPayload.issues;
+
+ return {
+ hasIssueDetails: storedPayload.hasIssueDetails,
+ issues: sortIssues(filteredIssues),
+ };
+}
+
+export function readStoredLighthousePayload(
+ payloadJson: string,
+ categoryFilter?: LighthouseCategory,
+) {
+ const storedPayload = parseStoredLighthousePayload(payloadJson);
+
+ return {
+ storedPayload,
+ report: buildLighthouseIssueReport(storedPayload, categoryFilter),
+ };
+}
+
+export function buildLighthouseExportFile(input: {
+ idField: "auditId" | "resultId";
+ idValue: string;
+ finalUrl: string;
+ strategy: "mobile" | "desktop";
+ createdAt: string;
+ payloadJson: string;
+ mode: ExportMode;
+ category?: LighthouseCategory;
+}) {
+ const safeDate = input.createdAt.replace(/[:.]/g, "-");
+ const baseName = `lighthouse-${input.strategy}-${safeDate}`;
+
+ if (input.mode === "full") {
+ return {
+ filename: `${baseName}-payload.json`,
+ content: input.payloadJson,
+ };
+ }
+
+ const { report } = readStoredLighthousePayload(
+ input.payloadJson,
+ input.category,
+ );
+
+ return {
+ filename:
+ input.mode === "category" && input.category
+ ? `${baseName}-${input.category}-issues.json`
+ : `${baseName}-issues.json`,
+ content: JSON.stringify(
+ {
+ [input.idField]: input.idValue,
+ finalUrl: input.finalUrl,
+ strategy: input.strategy,
+ createdAt: input.createdAt,
+ category: input.category ?? "all",
+ issues: report.issues,
+ },
+ null,
+ 2,
+ ),
+ };
+}
diff --git a/src/server/lib/lighthouseStoredPayload.test.ts b/src/server/lib/lighthouseStoredPayload.test.ts
new file mode 100644
index 0000000..1b467ae
--- /dev/null
+++ b/src/server/lib/lighthouseStoredPayload.test.ts
@@ -0,0 +1,159 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildStoredLighthouseIssues,
+ buildStoredLighthouseMetrics,
+} from "@/server/lib/lighthouseStoredPayload";
+
+describe("lighthouse stored payload classification", () => {
+ it("keeps actionable audits but separates metrics and diagnostics", () => {
+ const audits = {
+ interactive: {
+ title: "Time to Interactive",
+ score: 0.13,
+ scoreDisplayMode: "numeric",
+ displayValue: "12.8 s",
+ numericValue: 12800,
+ },
+ "largest-contentful-paint-element": {
+ title: "Largest Contentful Paint element",
+ score: 0,
+ scoreDisplayMode: "metricSavings",
+ displayValue: "3,630 ms",
+ },
+ "unused-javascript": {
+ title: "Reduce unused JavaScript",
+ description: "Trim dead code.",
+ score: 0.5,
+ scoreDisplayMode: "metricSavings",
+ displayValue: "Potential savings of 227 KiB",
+ details: {
+ overallSavingsBytes: 232886,
+ },
+ },
+ "color-contrast": {
+ title:
+ "Background and foreground colors do not have a sufficient contrast ratio.",
+ description: "Improve contrast.",
+ score: 0,
+ scoreDisplayMode: "binary",
+ },
+ };
+
+ const categories = {
+ performance: {
+ auditRefs: [
+ { id: "interactive" },
+ { id: "largest-contentful-paint-element" },
+ { id: "unused-javascript" },
+ ],
+ },
+ accessibility: {
+ auditRefs: [{ id: "color-contrast" }],
+ },
+ "best-practices": { auditRefs: [] },
+ seo: { auditRefs: [] },
+ };
+
+ const issues = buildStoredLighthouseIssues({ audits, categories });
+ const metrics = buildStoredLighthouseMetrics({ audits });
+
+ expect(issues.issues.map((issue) => issue.auditKey)).toEqual([
+ "unused-javascript",
+ "color-contrast",
+ ]);
+ expect(metrics.timeToInteractive.displayValue).toBe("12.8 s");
+ expect(metrics.timeToInteractive.score).toBe(13);
+ });
+
+ it("skips passing and non-actionable audits even when they appear in audit refs", () => {
+ const audits = {
+ passBinary: {
+ title: "Serve images in next-gen formats",
+ score: 1,
+ scoreDisplayMode: "binary",
+ },
+ informative: {
+ title: "User Timing marks and measures",
+ score: 0,
+ scoreDisplayMode: "informative",
+ },
+ manual: {
+ title: "Structured data is valid",
+ score: 0,
+ scoreDisplayMode: "manual",
+ },
+ notApplicable: {
+ title: "Uses optimized images",
+ score: 0,
+ scoreDisplayMode: "notApplicable",
+ },
+ errorAudit: {
+ title: "`[accesskey]` values are unique",
+ score: null,
+ scoreDisplayMode: "error",
+ },
+ goodScore: {
+ title: "Reduce unused CSS",
+ score: 0.96,
+ scoreDisplayMode: "metricSavings",
+ },
+ };
+
+ const categories = {
+ performance: {
+ auditRefs: [
+ { id: "passBinary" },
+ { id: "informative" },
+ { id: "manual" },
+ { id: "notApplicable" },
+ { id: "goodScore" },
+ ],
+ },
+ accessibility: {
+ auditRefs: [{ id: "errorAudit" }],
+ },
+ "best-practices": { auditRefs: [] },
+ seo: { auditRefs: [] },
+ };
+
+ const issues = buildStoredLighthouseIssues({ audits, categories });
+
+ expect(issues.hasIssueDetails).toBe(true);
+ expect(issues.issues).toEqual([]);
+ });
+
+ it("compacts affected items and caps them at ten entries", () => {
+ const items = Array.from({ length: 12 }, (_, index) => ({
+ url: `https://cdn.example.com/script-${index}.js`,
+ wastedBytes: 1000 + index,
+ extraField: "ignored",
+ }));
+
+ const issues = buildStoredLighthouseIssues({
+ audits: {
+ "unused-javascript": {
+ title: "Reduce unused JavaScript",
+ description: "Trim dead code.",
+ score: 0,
+ scoreDisplayMode: "metricSavings",
+ details: {
+ overallSavingsBytes: 50000,
+ items,
+ },
+ },
+ },
+ categories: {
+ performance: { auditRefs: [{ id: "unused-javascript" }] },
+ accessibility: { auditRefs: [] },
+ "best-practices": { auditRefs: [] },
+ seo: { auditRefs: [] },
+ },
+ });
+
+ expect(issues.issues).toHaveLength(1);
+ expect(issues.issues[0]?.items).toHaveLength(10);
+ expect(issues.issues[0]?.items[0]).toBe(
+ '{"url":"https://cdn.example.com/script-0.js","wastedBytes":1000}',
+ );
+ });
+});
diff --git a/src/server/lib/lighthouseStoredPayload.ts b/src/server/lib/lighthouseStoredPayload.ts
new file mode 100644
index 0000000..744b0e5
--- /dev/null
+++ b/src/server/lib/lighthouseStoredPayload.ts
@@ -0,0 +1,310 @@
+import { z } from "zod";
+import {
+ LIGHTHOUSE_CATEGORIES,
+ type LighthouseCategory,
+} from "@/shared/lighthouse";
+
+export type StoredLighthouseIssue = {
+ category: LighthouseCategory;
+ auditKey: string;
+ title: string;
+ description: string;
+ score: number | null;
+ scoreDisplayMode: string | null;
+ displayValue: string | null;
+ impactMs: number | null;
+ impactBytes: number | null;
+ severity: "critical" | "warning" | "info";
+ items: string[];
+};
+
+type StoredLighthouseMetric = {
+ score: number | null;
+ displayValue: string | null;
+ numericValue: number | null;
+};
+
+export type StoredLighthouseMetrics = {
+ firstContentfulPaint: StoredLighthouseMetric;
+ largestContentfulPaint: StoredLighthouseMetric;
+ totalBlockingTime: StoredLighthouseMetric;
+ cumulativeLayoutShift: StoredLighthouseMetric;
+ speedIndex: StoredLighthouseMetric;
+ timeToInteractive: StoredLighthouseMetric;
+ interactionToNextPaint: StoredLighthouseMetric;
+ serverResponseTime: StoredLighthouseMetric;
+};
+
+export type StoredLighthousePayload = {
+ version: 2;
+ source: "dataforseo-lighthouse";
+ hasIssueDetails: boolean;
+ metadata: {
+ requestedUrl: string;
+ finalUrl: string;
+ strategy: "mobile" | "desktop";
+ fetchedAt: string;
+ lighthouseVersion: string | null;
+ taskId: string | null;
+ cost: number | null;
+ };
+ scores: {
+ performance: number | null;
+ accessibility: number | null;
+ "best-practices": number | null;
+ seo: number | null;
+ };
+ metrics: StoredLighthouseMetrics;
+ issues: StoredLighthouseIssue[];
+};
+
+export type RawLighthouseAudit = {
+ title?: string;
+ description?: string;
+ score?: number | null;
+ scoreDisplayMode?: string;
+ displayValue?: string;
+ numericValue?: number;
+ details?: {
+ overallSavingsMs?: number;
+ overallSavingsBytes?: number;
+ items?: Array>;
+ };
+};
+
+export type RawLighthouseCategory = {
+ score?: number | null;
+ auditRefs?: Array<{
+ id?: string;
+ }>;
+};
+
+const storedLighthouseMetricSchema = z.object({
+ score: z.number().nullable(),
+ displayValue: z.string().nullable(),
+ numericValue: z.number().nullable(),
+});
+
+export const storedLighthousePayloadSchema = z.object({
+ version: z.literal(2),
+ source: z.literal("dataforseo-lighthouse"),
+ hasIssueDetails: z.boolean(),
+ metadata: z.object({
+ requestedUrl: z.string(),
+ finalUrl: z.string(),
+ strategy: z.enum(["mobile", "desktop"]),
+ fetchedAt: z.string(),
+ lighthouseVersion: z.string().nullable(),
+ taskId: z.string().nullable(),
+ cost: z.number().nullable(),
+ }),
+ scores: z.object({
+ performance: z.number().nullable(),
+ accessibility: z.number().nullable(),
+ "best-practices": z.number().nullable(),
+ seo: z.number().nullable(),
+ }),
+ metrics: z.object({
+ firstContentfulPaint: storedLighthouseMetricSchema,
+ largestContentfulPaint: storedLighthouseMetricSchema,
+ totalBlockingTime: storedLighthouseMetricSchema,
+ cumulativeLayoutShift: storedLighthouseMetricSchema,
+ speedIndex: storedLighthouseMetricSchema,
+ timeToInteractive: storedLighthouseMetricSchema,
+ interactionToNextPaint: storedLighthouseMetricSchema,
+ serverResponseTime: storedLighthouseMetricSchema,
+ }),
+ issues: z.array(
+ z.object({
+ category: z.enum(LIGHTHOUSE_CATEGORIES),
+ auditKey: z.string(),
+ title: z.string(),
+ description: z.string(),
+ score: z.number().nullable(),
+ scoreDisplayMode: z.string().nullable(),
+ displayValue: z.string().nullable(),
+ impactMs: z.number().nullable(),
+ impactBytes: z.number().nullable(),
+ severity: z.enum(["critical", "warning", "info"]),
+ items: z.array(z.string()),
+ }),
+ ),
+});
+
+export function scoreToPercent(
+ score: number | null | undefined,
+): number | null {
+ if (score == null || Number.isNaN(score)) return null;
+ return Math.round(score * 100);
+}
+
+function buildStoredMetric(
+ audit: RawLighthouseAudit | undefined,
+): StoredLighthouseMetric {
+ return {
+ score: scoreToPercent(audit?.score),
+ displayValue: audit?.displayValue ?? null,
+ numericValue:
+ typeof audit?.numericValue === "number" ? audit.numericValue : null,
+ };
+}
+
+const DIAGNOSTIC_AUDIT_KEYS = new Set([
+ "largest-contentful-paint-element",
+ "layout-shifts",
+ "diagnostics",
+ "metrics",
+ "network-requests",
+ "network-rtt",
+ "network-server-latency",
+ "main-thread-tasks",
+ "screenshot-thumbnails",
+ "final-screenshot",
+ "script-treemap-data",
+ "resource-summary",
+]);
+
+function compactItem(item: Record): string {
+ const preferredKeys = [
+ "url",
+ "source",
+ "nodeLabel",
+ "snippet",
+ "totalBytes",
+ "wastedBytes",
+ "wastedMs",
+ "label",
+ "value",
+ ];
+
+ const output: Record = {};
+ for (const key of preferredKeys) {
+ if (item[key] != null) {
+ output[key] = item[key];
+ }
+ }
+
+ if (Object.keys(output).length === 0) {
+ for (const [key, value] of Object.entries(item).slice(0, 6)) {
+ output[key] = value;
+ }
+ }
+
+ return JSON.stringify(output);
+}
+
+function getSeverity(input: {
+ score: number | null;
+ impactMs: number | null;
+ impactBytes: number | null;
+}): "critical" | "warning" | "info" {
+ if ((input.impactMs ?? 0) >= 300 || (input.impactBytes ?? 0) >= 150_000) {
+ return "critical";
+ }
+
+ if (input.score != null && input.score < 50) {
+ return "critical";
+ }
+
+ if ((input.impactMs ?? 0) >= 100 || (input.impactBytes ?? 0) >= 50_000) {
+ return "warning";
+ }
+
+ if (input.score != null && input.score < 90) {
+ return "warning";
+ }
+
+ return "info";
+}
+
+export function buildStoredLighthouseIssues(input: {
+ audits: Record;
+ categories: Record;
+}) {
+ const hasIssueDetails = LIGHTHOUSE_CATEGORIES.some(
+ (category) => (input.categories[category]?.auditRefs?.length ?? 0) > 0,
+ );
+
+ const issues: StoredLighthouseIssue[] = [];
+
+ for (const category of LIGHTHOUSE_CATEGORIES) {
+ const refs = input.categories[category]?.auditRefs ?? [];
+ for (const ref of refs) {
+ const auditKey = ref.id;
+ if (!auditKey) continue;
+
+ const audit = input.audits[auditKey];
+ if (!audit) continue;
+
+ const score = scoreToPercent(audit.score);
+ const scoreDisplayMode = audit.scoreDisplayMode ?? null;
+
+ if (scoreDisplayMode === "numeric") continue;
+ if (DIAGNOSTIC_AUDIT_KEYS.has(auditKey)) continue;
+
+ const isPass =
+ score == null ||
+ (score != null && score >= 90) ||
+ scoreDisplayMode === "notApplicable" ||
+ scoreDisplayMode === "informative" ||
+ scoreDisplayMode === "manual" ||
+ scoreDisplayMode === "error";
+
+ if (isPass) continue;
+
+ const impactMs =
+ typeof audit.details?.overallSavingsMs === "number"
+ ? audit.details.overallSavingsMs
+ : null;
+ const impactBytes =
+ typeof audit.details?.overallSavingsBytes === "number"
+ ? audit.details.overallSavingsBytes
+ : null;
+ const items = Array.isArray(audit.details?.items)
+ ? audit.details.items.slice(0, 10).map(compactItem)
+ : [];
+
+ issues.push({
+ category,
+ auditKey,
+ title: audit.title ?? auditKey,
+ description: audit.description ?? "",
+ score,
+ scoreDisplayMode,
+ displayValue: audit.displayValue ?? null,
+ impactMs,
+ impactBytes,
+ severity: getSeverity({ score, impactMs, impactBytes }),
+ items,
+ });
+ }
+ }
+
+ return {
+ hasIssueDetails,
+ issues,
+ };
+}
+
+export function buildStoredLighthouseMetrics(input: {
+ audits: Record;
+}): StoredLighthouseMetrics {
+ return {
+ firstContentfulPaint: buildStoredMetric(
+ input.audits["first-contentful-paint"],
+ ),
+ largestContentfulPaint: buildStoredMetric(
+ input.audits["largest-contentful-paint"],
+ ),
+ totalBlockingTime: buildStoredMetric(input.audits["total-blocking-time"]),
+ cumulativeLayoutShift: buildStoredMetric(
+ input.audits["cumulative-layout-shift"],
+ ),
+ speedIndex: buildStoredMetric(input.audits["speed-index"]),
+ timeToInteractive: buildStoredMetric(input.audits.interactive),
+ interactionToNextPaint: buildStoredMetric(
+ input.audits["interaction-to-next-paint"],
+ ),
+ serverResponseTime: buildStoredMetric(input.audits["server-response-time"]),
+ };
+}
diff --git a/src/server/workflows/SiteAuditWorkflow.ts b/src/server/workflows/SiteAuditWorkflow.ts
index 5490347..9f63eb1 100644
--- a/src/server/workflows/SiteAuditWorkflow.ts
+++ b/src/server/workflows/SiteAuditWorkflow.ts
@@ -9,12 +9,14 @@ import {
type WorkflowEvent,
type WorkflowStep,
} from "cloudflare:workers";
+import type { BillingCustomerContext } from "@/server/billing/subscription";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import type { AuditConfig } from "@/server/lib/audit/types";
import { runAuditPhases } from "@/server/workflows/siteAuditWorkflowPhases";
interface AuditParams {
auditId: string;
+ billingCustomer: BillingCustomerContext;
projectId: string;
startUrl: string;
config: AuditConfig;
@@ -22,7 +24,8 @@ interface AuditParams {
export class SiteAuditWorkflow extends WorkflowEntrypoint {
async run(event: WorkflowEvent, step: WorkflowStep) {
- const { auditId, projectId, startUrl, config } = event.payload;
+ const { auditId, billingCustomer, projectId, startUrl, config } =
+ event.payload;
const audit = await AuditRepository.getAuditForWorkflow(
auditId,
@@ -41,6 +44,7 @@ export class SiteAuditWorkflow extends WorkflowEntrypoint {
await runAuditPhases(step, {
auditId,
workflowInstanceId: event.instanceId,
+ billingCustomer,
projectId,
startUrl,
config,
diff --git a/src/server/workflows/site-audit-workflow-helpers.ts b/src/server/workflows/site-audit-workflow-helpers.ts
index 27f26bd..82f7ec5 100644
--- a/src/server/workflows/site-audit-workflow-helpers.ts
+++ b/src/server/workflows/site-audit-workflow-helpers.ts
@@ -1,64 +1,6 @@
import { analyzeHtml } from "@/server/lib/audit/page-analyzer";
-import { fetchPsiResult } from "@/server/lib/audit/psi";
+import type { StepPageResult } from "@/server/lib/audit/types";
import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils";
-import type { PsiResult } from "@/server/lib/audit/types";
-import { putTextToR2 } from "@/server/lib/r2";
-
-export interface StepPageResult {
- id: string;
- url: string;
- statusCode: number;
- redirectUrl: string | null;
- title: string;
- metaDescription: string;
- canonicalUrl: string | null;
- robotsMeta: string | null;
- ogTitle: string | null;
- ogDescription: string | null;
- ogImage: string | null;
- h1Count: number;
- h2Count: number;
- h3Count: number;
- h4Count: number;
- h5Count: number;
- h6Count: number;
- headingOrder: number[];
- wordCount: number;
- imagesTotal: number;
- imagesMissingAlt: number;
- images: Array<{ src: string | null; alt: string | null }>;
- internalLinks: string[];
- externalLinks: string[];
- hasStructuredData: boolean;
- hreflangTags: string[];
- isIndexable: boolean;
- responseTimeMs: number;
-}
-
-type PsiUploadContext = {
- projectId: string;
- auditId: string;
-};
-
-export async function fetchPsiAndUploadToR2(
- url: string,
- pageId: string,
- strategy: "mobile" | "desktop",
- apiKey: string,
- context: PsiUploadContext,
-): Promise {
- const result = await fetchPsiResult(url, pageId, strategy, apiKey);
-
- if (result.rawPayloadJson) {
- const key = `site-audit/${context.projectId}/${context.auditId}/${pageId}-${strategy}.json`;
- const uploaded = await putTextToR2(key, result.rawPayloadJson);
- result.r2Key = uploaded.key;
- result.payloadSizeBytes = uploaded.sizeBytes;
- result.rawPayloadJson = null;
- }
-
- return result;
-}
export async function crawlPage(
url: string,
diff --git a/src/server/workflows/siteAuditWorkflowCrawl.ts b/src/server/workflows/siteAuditWorkflowCrawl.ts
index b9907f9..8245600 100644
--- a/src/server/workflows/siteAuditWorkflowCrawl.ts
+++ b/src/server/workflows/siteAuditWorkflowCrawl.ts
@@ -1,12 +1,10 @@
import type { WorkflowStep } from "cloudflare:workers";
import type { RobotsResult } from "@/server/lib/audit/discovery";
+import type { StepPageResult } from "@/server/lib/audit/types";
import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
-import {
- crawlPage,
- type StepPageResult,
-} from "@/server/workflows/site-audit-workflow-helpers";
+import { crawlPage } from "@/server/workflows/site-audit-workflow-helpers";
const CRAWL_CONCURRENCY = 25;
diff --git a/src/server/workflows/siteAuditWorkflowPhases.ts b/src/server/workflows/siteAuditWorkflowPhases.ts
index f1dd2e7..0b39715 100644
--- a/src/server/workflows/siteAuditWorkflowPhases.ts
+++ b/src/server/workflows/siteAuditWorkflowPhases.ts
@@ -1,19 +1,23 @@
import type { WorkflowStep } from "cloudflare:workers";
+import type { BillingCustomerContext } from "@/server/billing/subscription";
import { discoverUrls, fetchRobotsTxt } from "@/server/lib/audit/discovery";
-import { selectPsiSample } from "@/server/lib/audit/psi";
+import {
+ fetchAndStoreLighthouseResult,
+ selectLighthouseSample,
+} from "@/server/lib/audit/lighthouse";
import { getOrigin } from "@/server/lib/audit/url-utils";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
-import type { AuditConfig, PsiResult } from "@/server/lib/audit/types";
-import {
- fetchPsiAndUploadToR2,
- type StepPageResult,
-} from "@/server/workflows/site-audit-workflow-helpers";
+import type {
+ AuditConfig,
+ LighthouseResult,
+ StepPageResult,
+} from "@/server/lib/audit/types";
import { runCrawlPhase } from "@/server/workflows/siteAuditWorkflowCrawl";
-const PSI_URL_CONCURRENCY = 6;
+const LIGHTHOUSE_URL_BATCH_SIZE = 10;
-function countPsiBatchResults(results: PsiResult[]): {
+function countLighthouseBatchResults(results: LighthouseResult[]): {
completed: number;
failed: number;
} {
@@ -32,6 +36,7 @@ function countPsiBatchResults(results: PsiResult[]): {
type AuditPhasesParams = {
auditId: string;
workflowInstanceId: string;
+ billingCustomer: BillingCustomerContext;
projectId: string;
startUrl: string;
config: AuditConfig;
@@ -41,7 +46,14 @@ export async function runAuditPhases(
step: WorkflowStep,
params: AuditPhasesParams,
) {
- const { auditId, workflowInstanceId, projectId, startUrl, config } = params;
+ const {
+ auditId,
+ workflowInstanceId,
+ billingCustomer,
+ projectId,
+ startUrl,
+ config,
+ } = params;
const origin = getOrigin(startUrl);
const maxPages = config.maxPages;
@@ -62,15 +74,22 @@ export async function runAuditPhases(
robots,
sitemapUrls: discovery.sitemapUrls,
});
- const psiResults = await runPsiPhase(step, {
+ const lighthouseResults = await runLighthousePhase(step, {
auditId,
workflowInstanceId,
+ billingCustomer,
projectId,
startUrl,
config,
allPages,
});
- await finalizeAudit(step, auditId, workflowInstanceId, allPages, psiResults);
+ await finalizeAudit(
+ step,
+ auditId,
+ workflowInstanceId,
+ allPages,
+ lighthouseResults,
+ );
}
async function runDiscoveryPhase(
@@ -90,115 +109,134 @@ async function runDiscoveryPhase(
});
}
-type PsiPhaseParams = {
+type LighthousePhaseParams = {
auditId: string;
workflowInstanceId: string;
+ billingCustomer: BillingCustomerContext;
projectId: string;
startUrl: string;
config: AuditConfig;
allPages: StepPageResult[];
};
-async function runPsiPhase(
+async function runLighthousePhase(
step: WorkflowStep,
- params: PsiPhaseParams,
-): Promise {
- const { auditId, workflowInstanceId, projectId, startUrl, config, allPages } =
- params;
- if (config.psiStrategy === "none" || !config.psiApiKey) return [];
+ params: LighthousePhaseParams,
+): Promise {
+ const {
+ auditId,
+ workflowInstanceId,
+ billingCustomer,
+ projectId,
+ startUrl,
+ config,
+ allPages,
+ } = params;
+ if (config.lighthouseStrategy === "none") return [];
- const psiSample = await selectPsiUrls({
+ const lighthouseWork = await selectLighthousePages({
step,
auditId,
workflowInstanceId,
allPages,
startUrl,
- strategy: config.psiStrategy,
- });
- const psiWork = psiSample.flatMap((psiUrl) => {
- const page = allPages.find((candidate) => candidate.url === psiUrl);
- if (!page) return [];
- return [{ url: psiUrl, pageId: page.id }];
+ strategy: config.lighthouseStrategy,
});
- const psiResults: PsiResult[] = [];
- let psiCompleted = 0;
- let psiFailed = 0;
- let psiBatchIndex = 0;
+ const lighthouseResults: LighthouseResult[] = [];
+ let completedChecks = 0;
+ let failedChecks = 0;
+ let lighthouseBatchIndex = 0;
- for (let i = 0; i < psiWork.length; i += PSI_URL_CONCURRENCY) {
- const batch = psiWork.slice(i, i + PSI_URL_CONCURRENCY);
- psiBatchIndex += 1;
- const psiBatchResults = await runPsiBatch({
+ for (let i = 0; i < lighthouseWork.length; i += LIGHTHOUSE_URL_BATCH_SIZE) {
+ const batch = lighthouseWork.slice(i, i + LIGHTHOUSE_URL_BATCH_SIZE);
+ lighthouseBatchIndex += 1;
+ const lighthouseBatchResults = await runLighthouseBatch({
step,
- psiBatchIndex,
+ lighthouseBatchIndex,
batch,
- psiApiKey: config.psiApiKey,
+ billingCustomer,
projectId,
auditId,
});
- psiResults.push(...psiBatchResults);
- const counts = countPsiBatchResults(psiBatchResults);
- psiFailed += counts.failed;
- psiCompleted += counts.completed;
- await step.do(`psi-progress-batch-${psiBatchIndex}`, async () => {
- await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
- psiCompleted,
- psiFailed,
- });
- });
+ lighthouseResults.push(...lighthouseBatchResults);
+ const counts = countLighthouseBatchResults(lighthouseBatchResults);
+ failedChecks += counts.failed;
+ completedChecks += counts.completed;
+ await step.do(
+ `lighthouse-progress-batch-${lighthouseBatchIndex}`,
+ async () => {
+ await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
+ lighthouseCompleted: completedChecks,
+ lighthouseFailed: failedChecks,
+ });
+ },
+ );
}
- return psiResults;
+ return lighthouseResults;
}
-async function selectPsiUrls(params: {
+async function selectLighthousePages(params: {
step: WorkflowStep;
auditId: string;
workflowInstanceId: string;
allPages: StepPageResult[];
startUrl: string;
- strategy: AuditConfig["psiStrategy"];
+ strategy: AuditConfig["lighthouseStrategy"];
}) {
const { step, auditId, workflowInstanceId, allPages, startUrl, strategy } =
params;
- return step.do("select-psi-sample", async () => {
- const pagesForSample = allPages.map((page) => ({
- id: page.id,
- url: page.url,
- statusCode: page.statusCode,
- }));
- const sample = selectPsiSample(pagesForSample, startUrl, strategy);
+ return step.do("select-lighthouse-sample", async () => {
+ const sample = selectLighthouseSample(allPages, startUrl, strategy);
+ const selectedUrls = new Set(sample);
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
- currentPhase: "psi",
- psiTotal: sample.length * 2,
- psiCompleted: 0,
- psiFailed: 0,
+ currentPhase: "lighthouse",
+ lighthouseTotal: sample.length * 2,
+ lighthouseCompleted: 0,
+ lighthouseFailed: 0,
});
- return sample;
+ return allPages.flatMap((page) =>
+ selectedUrls.has(page.url) ? [{ url: page.url, pageId: page.id }] : [],
+ );
});
}
-async function runPsiBatch(params: {
+async function runLighthouseBatch(params: {
step: WorkflowStep;
- psiBatchIndex: number;
+ lighthouseBatchIndex: number;
batch: Array<{ url: string; pageId: string }>;
- psiApiKey: string;
+ billingCustomer: BillingCustomerContext;
projectId: string;
auditId: string;
}) {
- const { step, psiBatchIndex, batch, psiApiKey, projectId, auditId } = params;
- return step.do(`psi-batch-${psiBatchIndex}`, async () => {
+ const {
+ step,
+ lighthouseBatchIndex,
+ batch,
+ billingCustomer,
+ projectId,
+ auditId,
+ } = params;
+ return step.do(`lighthouse-batch-${lighthouseBatchIndex}`, async () => {
const perUrlResults = await Promise.all(
batch.map(async ({ url, pageId }) => {
const [mobileResult, desktopResult] = await Promise.all([
- fetchPsiAndUploadToR2(url, pageId, "mobile", psiApiKey, {
+ fetchAndStoreLighthouseResult({
+ url,
+ pageId,
+ strategy: "mobile",
+ billingCustomer,
projectId,
auditId,
}),
- fetchPsiAndUploadToR2(url, pageId, "desktop", psiApiKey, {
+ fetchAndStoreLighthouseResult({
+ url,
+ pageId,
+ strategy: "desktop",
+ billingCustomer,
projectId,
auditId,
}),
@@ -216,13 +254,17 @@ async function finalizeAudit(
auditId: string,
workflowInstanceId: string,
allPages: StepPageResult[],
- psiResults: PsiResult[],
+ lighthouseResults: LighthouseResult[],
) {
await step.do("finalize", async () => {
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
currentPhase: "finalizing",
});
- await AuditRepository.batchWriteResults(auditId, allPages, psiResults);
+ await AuditRepository.batchWriteResults(
+ auditId,
+ allPages,
+ lighthouseResults,
+ );
await AuditRepository.completeAudit(auditId, workflowInstanceId, {
pagesCrawled: allPages.length,
pagesTotal: allPages.length,
diff --git a/src/serverFunctions/audit.ts b/src/serverFunctions/audit.ts
index 278cc69..92627a9 100644
--- a/src/serverFunctions/audit.ts
+++ b/src/serverFunctions/audit.ts
@@ -1,14 +1,14 @@
import { createServerFn } from "@tanstack/react-start";
+import { AuditService } from "@/server/features/audit/services/AuditService";
import { requireProjectContext } from "@/serverFunctions/middleware";
import {
- startAuditSchema,
- getAuditStatusSchema,
- getAuditResultsSchema,
- getAuditHistorySchema,
deleteAuditSchema,
+ getAuditHistorySchema,
+ getAuditResultsSchema,
+ getAuditStatusSchema,
getCrawlProgressSchema,
+ startAuditSchema,
} from "@/types/schemas/audit";
-import { AuditService } from "@/server/features/audit/services/AuditService";
export const startAudit = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
@@ -16,11 +16,14 @@ export const startAudit = createServerFn({ method: "POST" })
.handler(async ({ data, context }) => {
return AuditService.startAudit({
actorUserId: context.userId,
+ billingCustomer: {
+ organizationId: context.organizationId,
+ userEmail: context.userEmail,
+ },
projectId: context.project.id,
startUrl: data.startUrl,
maxPages: data.maxPages,
- psiStrategy: data.psiStrategy,
- psiApiKey: data.psiApiKey,
+ lighthouseStrategy: data.lighthouseStrategy,
});
});
@@ -38,9 +41,7 @@ export const getAuditResults = createServerFn({ method: "POST" })
return AuditService.getResults(data.auditId, context.project.id);
});
-export const getAuditHistory = createServerFn({
- method: "POST",
-})
+export const getAuditHistory = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => getAuditHistorySchema.parse(data))
.handler(async ({ context }) => {
diff --git a/src/serverFunctions/lighthouse.ts b/src/serverFunctions/lighthouse.ts
new file mode 100644
index 0000000..107c99b
--- /dev/null
+++ b/src/serverFunctions/lighthouse.ts
@@ -0,0 +1,88 @@
+import { createServerFn } from "@tanstack/react-start";
+import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
+import {
+ buildLighthouseExportFile,
+ readStoredLighthousePayload,
+} from "@/server/lib/lighthousePayload";
+import { AppError } from "@/server/lib/errors";
+import { getJsonFromR2 } from "@/server/lib/r2";
+import { requireProjectContext } from "@/serverFunctions/middleware";
+import {
+ lighthouseAuditExportSchema,
+ lighthouseAuditIssueSchema,
+} from "@/types/schemas/lighthouse";
+
+async function getAuditLighthouseData(input: {
+ projectId: string;
+ resultId: string;
+}) {
+ const site = await AuditRepository.getLighthouseResultById({
+ lighthouseResultId: input.resultId,
+ projectId: input.projectId,
+ });
+
+ if (!site) {
+ throw new AppError("NOT_FOUND");
+ }
+
+ const r2Key = site.lighthouse.r2Key;
+ if (!r2Key) {
+ throw new AppError("NOT_FOUND");
+ }
+
+ const payloadJson = await getJsonFromR2(r2Key);
+ const payload = readStoredLighthousePayload(payloadJson);
+
+ return {
+ id: site.lighthouse.id,
+ strategy: site.lighthouse.strategy,
+ finalUrl: site.page?.url ?? "",
+ createdAt: site.audit.startedAt,
+ payloadJson,
+ payload,
+ };
+}
+
+export const getAuditLighthouseIssues = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => lighthouseAuditIssueSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ const lighthouse = await getAuditLighthouseData({
+ projectId: context.project.id,
+ resultId: data.resultId,
+ });
+
+ return {
+ id: lighthouse.id,
+ finalUrl:
+ lighthouse.payload.storedPayload?.metadata.finalUrl ??
+ lighthouse.finalUrl,
+ strategy: lighthouse.strategy,
+ createdAt: lighthouse.createdAt,
+ hasIssueDetails: lighthouse.payload.report.hasIssueDetails,
+ scores: lighthouse.payload.storedPayload?.scores ?? null,
+ metrics: lighthouse.payload.storedPayload?.metrics ?? null,
+ issues: lighthouse.payload.report.issues,
+ };
+ });
+
+export const exportAuditLighthouseIssues = createServerFn({ method: "POST" })
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => lighthouseAuditExportSchema.parse(data))
+ .handler(async ({ data, context }) => {
+ const lighthouse = await getAuditLighthouseData({
+ projectId: context.project.id,
+ resultId: data.resultId,
+ });
+
+ return buildLighthouseExportFile({
+ idField: "resultId",
+ idValue: lighthouse.id,
+ finalUrl: lighthouse.finalUrl,
+ strategy: lighthouse.strategy,
+ createdAt: lighthouse.createdAt,
+ payloadJson: lighthouse.payloadJson,
+ mode: data.mode,
+ category: data.mode === "category" ? data.category : undefined,
+ });
+ });
diff --git a/src/serverFunctions/projects.ts b/src/serverFunctions/projects.ts
index 6aec5a6..ecd3045 100644
--- a/src/serverFunctions/projects.ts
+++ b/src/serverFunctions/projects.ts
@@ -1,9 +1,6 @@
import { createServerFn } from "@tanstack/react-start";
import { ProjectService } from "@/server/features/projects/services/ProjectService";
-import {
- requireAuthenticatedContext,
- requireProjectContext,
-} from "@/serverFunctions/middleware";
+import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
import { z } from "zod";
export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
@@ -13,13 +10,13 @@ export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
);
export const getProjectAccess = createServerFn({ method: "POST" })
- .middleware(requireProjectContext)
+ .middleware(requireAuthenticatedContext)
.inputValidator((data: unknown) =>
z.object({ projectId: z.string().min(1) }).parse(data),
)
- .handler(async ({ context }) => {
+ .handler(async ({ data, context }) => {
return ProjectService.getProjectForOrganization(
context.organizationId,
- context.project.id,
+ data.projectId,
);
});
diff --git a/src/serverFunctions/psi.ts b/src/serverFunctions/psi.ts
deleted file mode 100644
index 1d0edd9..0000000
--- a/src/serverFunctions/psi.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { createServerFn } from "@tanstack/react-start";
-import { PsiAuditService } from "@/server/features/psi/services/PsiAuditService";
-import { requireProjectContext } from "@/serverFunctions/middleware";
-import {
- psiAuditIssueSchema,
- psiAuditExportSchema,
- psiProjectKeySchema,
- psiProjectSchema,
-} from "@/types/schemas/psi";
-
-export const getProjectPsiApiKey = createServerFn({
- method: "POST",
-})
- .middleware(requireProjectContext)
- .inputValidator((data: unknown) => psiProjectSchema.parse(data))
- .handler(async ({ context }) => {
- return PsiAuditService.getProjectPsiApiKey({
- projectId: context.project.id,
- });
- });
-
-export const saveProjectPsiApiKey = createServerFn({
- method: "POST",
-})
- .middleware(requireProjectContext)
- .inputValidator((data: unknown) => psiProjectKeySchema.parse(data))
- .handler(async ({ data, context }) => {
- return PsiAuditService.saveProjectPsiApiKey({
- projectId: context.project.id,
- apiKey: data.apiKey,
- });
- });
-
-export const clearProjectPsiApiKey = createServerFn({
- method: "POST",
-})
- .middleware(requireProjectContext)
- .inputValidator((data: unknown) => psiProjectSchema.parse(data))
- .handler(async ({ context }) => {
- return PsiAuditService.clearProjectPsiApiKey({
- projectId: context.project.id,
- });
- });
-
-export const getAuditPsiIssues = createServerFn({ method: "POST" })
- .middleware(requireProjectContext)
- .inputValidator((data: unknown) => psiAuditIssueSchema.parse(data))
- .handler(async ({ data, context }) => {
- return PsiAuditService.getAuditPsiIssues({
- projectId: context.project.id,
- resultId: data.resultId,
- category: data.category,
- });
- });
-
-export const exportAuditPsi = createServerFn({ method: "POST" })
- .middleware(requireProjectContext)
- .inputValidator((data: unknown) => psiAuditExportSchema.parse(data))
- .handler(async ({ data, context }) => {
- return PsiAuditService.exportAuditPsi({
- projectId: context.project.id,
- resultId: data.resultId,
- mode: data.mode,
- category: data.category,
- });
- });
diff --git a/src/shared/lighthouse.ts b/src/shared/lighthouse.ts
new file mode 100644
index 0000000..79f8bb1
--- /dev/null
+++ b/src/shared/lighthouse.ts
@@ -0,0 +1,14 @@
+export const LIGHTHOUSE_CATEGORIES = [
+ "performance",
+ "accessibility",
+ "best-practices",
+ "seo",
+] as const;
+
+export const LIGHTHOUSE_CATEGORY_TABS = [
+ "all",
+ ...LIGHTHOUSE_CATEGORIES,
+] as const;
+
+export type LighthouseCategory = (typeof LIGHTHOUSE_CATEGORIES)[number];
+export type LighthouseCategoryTab = (typeof LIGHTHOUSE_CATEGORY_TABS)[number];
diff --git a/src/types/schemas/audit.ts b/src/types/schemas/audit.ts
index 996350b..450e3c6 100644
--- a/src/types/schemas/audit.ts
+++ b/src/types/schemas/audit.ts
@@ -6,11 +6,10 @@ export const startAuditSchema = z.object({
projectId: z.string().min(1),
startUrl: z.string().min(1, "URL is required").max(2048),
maxPages: z.number().int().min(10).max(10_000).optional().default(50),
- psiStrategy: z
+ lighthouseStrategy: z
.enum(["auto", "all", "manual", "none"])
.optional()
.default("auto"),
- psiApiKey: z.string().optional(),
});
export const getAuditStatusSchema = z.object({
diff --git a/src/types/schemas/lighthouse.ts b/src/types/schemas/lighthouse.ts
new file mode 100644
index 0000000..dc19f11
--- /dev/null
+++ b/src/types/schemas/lighthouse.ts
@@ -0,0 +1,22 @@
+import { z } from "zod";
+import {
+ LIGHTHOUSE_CATEGORIES,
+ LIGHTHOUSE_CATEGORY_TABS,
+} from "@/shared/lighthouse";
+
+export const lighthouseAuditIssueSchema = z.object({
+ projectId: z.string().min(1, "Project id is required"),
+ resultId: z.string().min(1, "Result id is required"),
+});
+
+export const lighthouseAuditExportSchema = z.object({
+ projectId: z.string().min(1, "Project id is required"),
+ resultId: z.string().min(1, "Result id is required"),
+ mode: z.enum(["full", "issues", "category"]),
+ category: z.enum(LIGHTHOUSE_CATEGORIES).optional(),
+});
+
+export const lighthouseIssuesSearchSchema = z.object({
+ auditId: z.string().optional().catch(undefined),
+ category: z.enum(LIGHTHOUSE_CATEGORY_TABS).catch("all").default("all"),
+});
diff --git a/src/types/schemas/psi.ts b/src/types/schemas/psi.ts
deleted file mode 100644
index b0d9209..0000000
--- a/src/types/schemas/psi.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { z } from "zod";
-
-const psiCategories = [
- "performance",
- "accessibility",
- "best-practices",
- "seo",
-] as const;
-
-export const psiProjectKeySchema = z.object({
- projectId: z.string().min(1, "Project is required"),
- apiKey: z.string().min(1, "API key is required").max(512),
-});
-
-export const psiProjectSchema = z.object({
- projectId: z.string().min(1, "Project is required"),
-});
-
-export const psiAuditIssueSchema = z.object({
- projectId: z.string().min(1, "Project is required"),
- resultId: z.string().min(1, "Result id is required"),
- category: z.enum(psiCategories).optional(),
-});
-
-export const psiAuditExportSchema = z.object({
- projectId: z.string().min(1, "Project is required"),
- resultId: z.string().min(1, "Result id is required"),
- mode: z.enum(["full", "issues", "category"]),
- category: z.enum(psiCategories).optional(),
-});
-
-export const psiIssuesSearchSchema = z.object({
- category: z
- .enum(["all", ...psiCategories])
- .catch("all")
- .default("all"),
-});
|