refactor: move lighthouse audits to dataforseo (#43)

* refactor: move lighthouse audits to dataforseo

* chore: remove obsolete audit settings modal

* refactor: rename psi flows to lighthouse

* save

* refactor: simplify audit lighthouse storage flow

* fix: separate lighthouse metrics from actionable audits

* refactor: remove redundant audit project inputs

* feat: redesign lighthouse issues screen with score gauges and table layout

Replace flat score cards with circular SVG gauges, condense metrics into
a compact grid, and switch issue list from cards to an expandable table
with fixed column widths.

* test: harden lighthouse regression coverage

* fix: restore project-scoped audit inputs

* refactor: simplify lighthouse payload handling

* refactor: inline lighthouse server handlers

* refactor: share audit workflow types

* refactor: simplify lighthouse payload flows

* save

* refactor: drop project pagespeed api key

* fix: restore lighthouse issues loading with resilient project context

* fix: restore audit issues back navigation

* refactor: simplify project context and lighthouse error handling

* fix: tolerate DataForSEO lighthouse payload drift

* refactor: route audit lighthouse through dataforseo client
This commit is contained in:
Ben Senescu 2026-03-25 15:16:05 -04:00
parent 739b3f0b6a
commit 638f5a6602
59 changed files with 5726 additions and 1858 deletions

3
.gitignore vendored
View File

@ -32,3 +32,6 @@ dist/
# Localflare generated files
.localflare/
# Local Claude config
.claude/

View File

@ -243,7 +243,7 @@ That means you can try OpenSEO for free with the starter credit, then decide if/
- DataForSEO Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api
- DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks
- Google PageSpeed Insights API docs: https://developers.google.com/speed/docs/insights/v5/get-started
- DataForSEO Lighthouse API docs: https://docs.dataforseo.com/v3/on_page/lighthouse/overview/
### 1) Site audit

View File

@ -0,0 +1,30 @@
ALTER TABLE `audit_psi_results` RENAME TO `audit_lighthouse_results`;--> statement-breakpoint
ALTER TABLE `audits` RENAME COLUMN "psi_total" TO "lighthouse_total";--> statement-breakpoint
ALTER TABLE `audits` RENAME COLUMN "psi_completed" TO "lighthouse_completed";--> statement-breakpoint
ALTER TABLE `audits` RENAME COLUMN "psi_failed" TO "lighthouse_failed";--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_audit_lighthouse_results` (
`id` text PRIMARY KEY NOT NULL,
`audit_id` text NOT NULL,
`page_id` text NOT NULL,
`strategy` text NOT NULL,
`performance_score` integer,
`accessibility_score` integer,
`best_practices_score` integer,
`seo_score` integer,
`lcp_ms` real,
`cls` real,
`inp_ms` real,
`ttfb_ms` real,
`error_message` text,
`r2_key` text,
`payload_size_bytes` integer,
FOREIGN KEY (`audit_id`) REFERENCES `audits`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`page_id`) REFERENCES `audit_pages`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_audit_lighthouse_results`("id", "audit_id", "page_id", "strategy", "performance_score", "accessibility_score", "best_practices_score", "seo_score", "lcp_ms", "cls", "inp_ms", "ttfb_ms", "error_message", "r2_key", "payload_size_bytes") SELECT "id", "audit_id", "page_id", "strategy", "performance_score", "accessibility_score", "best_practices_score", "seo_score", "lcp_ms", "cls", "inp_ms", "ttfb_ms", "error_message", "r2_key", "payload_size_bytes" FROM `audit_lighthouse_results`;--> statement-breakpoint
DROP TABLE `audit_lighthouse_results`;--> statement-breakpoint
ALTER TABLE `__new_audit_lighthouse_results` RENAME TO `audit_lighthouse_results`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `audit_lighthouse_results_audit_id_idx` ON `audit_lighthouse_results` (`audit_id`);

View File

@ -0,0 +1 @@
ALTER TABLE `projects` DROP COLUMN `pagespeed_api_key`;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,20 @@
"when": 1773935379368,
"tag": "0004_faithful_sunset_bain",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1773965298920,
"tag": "0005_low_red_hulk",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1774320825595,
"tag": "0006_magical_alex_wilder",
"breakpoints": true
}
]
}

View File

@ -38,7 +38,7 @@ export function AuditHistorySection({
<th>URL</th>
<th>Status</th>
<th>Pages</th>
<th>PSI</th>
<th>Lighthouse</th>
<th></th>
</tr>
</thead>
@ -54,7 +54,7 @@ export function AuditHistorySection({
</td>
<td>{audit.pagesTotal || audit.pagesCrawled}</td>
<td>
{audit.ranPsi ? (
{audit.ranLighthouse ? (
<span className="badge badge-ghost badge-xs">Yes</span>
) : null}
</td>

View File

@ -1,48 +1,33 @@
import type { FormEvent } from "react";
import { Loader2, Settings } from "lucide-react";
import { Loader2 } from "lucide-react";
import {
MAX_PAGES_LIMIT,
MIN_PAGES,
type LaunchFormApi,
type LaunchState,
type SettingsFormApi,
} from "@/client/features/audit/launch/types";
export function LaunchFormCard({
launchForm,
settingsForm,
state,
setState,
isPending,
onSubmit,
onOpenSettings,
onRunPsiToggle,
onRunLighthouseToggle,
commitMaxPagesInput,
}: {
launchForm: LaunchFormApi;
settingsForm: SettingsFormApi;
state: LaunchState;
setState: React.Dispatch<React.SetStateAction<LaunchState>>;
isPending: boolean;
onSubmit: (event: FormEvent) => void;
onOpenSettings: () => void;
onRunPsiToggle: (checked: boolean) => void;
onRunLighthouseToggle: (checked: boolean) => void;
commitMaxPagesInput: () => number;
}) {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<div className="flex items-center justify-between">
<h2 className="card-title text-base">Start New Audit</h2>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onOpenSettings}
>
<Settings className="size-4" />
Settings
</button>
</div>
<form
className="grid grid-cols-1 gap-3 lg:grid-cols-12 lg:items-center"
@ -85,10 +70,9 @@ export function LaunchFormCard({
launchForm={launchForm}
commitMaxPagesInput={commitMaxPagesInput}
/>
<PsiOptions
<LighthouseOptions
launchForm={launchForm}
settingsForm={settingsForm}
onRunPsiToggle={onRunPsiToggle}
onRunLighthouseToggle={onRunLighthouseToggle}
/>
</div>
</form>
@ -138,42 +122,42 @@ function LaunchOptions({
);
}
function PsiOptions({
function LighthouseOptions({
launchForm,
settingsForm,
onRunPsiToggle,
onRunLighthouseToggle,
}: {
launchForm: LaunchFormApi;
settingsForm: SettingsFormApi;
onRunPsiToggle: (checked: boolean) => void;
onRunLighthouseToggle: (checked: boolean) => void;
}) {
return (
<div className="rounded-lg border border-base-300 bg-base-200/20 p-3 space-y-2">
<label className="label cursor-pointer justify-start gap-2 p-0">
<launchForm.Field name="runPsi">
<launchForm.Field name="runLighthouse">
{(field) => (
<input
type="checkbox"
className="toggle toggle-sm toggle-primary"
checked={Boolean(field.state.value)}
onChange={(event) => onRunPsiToggle(event.target.checked)}
onChange={(event) => onRunLighthouseToggle(event.target.checked)}
/>
)}
</launchForm.Field>
<span
className="text-sm font-medium text-base-content/80"
title="Run Google PageSpeed Insights checks during this audit"
title="Run Lighthouse checks through DataForSEO during this audit"
>
Include PSI checks
Include Lighthouse checks
</span>
</label>
<launchForm.Subscribe selector={(snapshot) => snapshot.values.runPsi}>
{(runPsi) =>
runPsi ? (
<launchForm.Subscribe
selector={(snapshot) => snapshot.values.runLighthouse}
>
{(runLighthouse) =>
runLighthouse ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-base-content/60">PSI mode</span>
<launchForm.Field name="psiMode">
<span className="text-xs text-base-content/60">Audit scope</span>
<launchForm.Field name="lighthouseMode">
{(field) => (
<select
className="select select-bordered select-xs"
@ -189,17 +173,9 @@ function PsiOptions({
</select>
)}
</launchForm.Field>
<settingsForm.Subscribe
selector={(snapshot) => snapshot.values.psiApiKey}
>
{(psiApiKey) => (
<span
className={`text-xs ${psiApiKey.trim() ? "text-success/80" : "text-warning"}`}
>
{psiApiKey.trim() ? "PSI key saved" : "PSI key required"}
<span className="text-xs text-base-content/50">
Powered by DataForSEO Lighthouse
</span>
)}
</settingsForm.Subscribe>
</div>
) : null
}
@ -214,11 +190,6 @@ function LaunchErrors({ state }: { state: LaunchState }) {
{state.urlError ? (
<p className="text-sm text-error">{state.urlError}</p>
) : null}
{state.psiRequirementError ? (
<div className="alert alert-warning py-2">
<span className="text-sm">{state.psiRequirementError}</span>
</div>
) : null}
{state.startError ? (
<div className="alert alert-error py-2">
<span className="text-sm">{state.startError}</span>

View File

@ -1,6 +1,5 @@
import { AuditHistorySection } from "@/client/features/audit/launch/AuditHistorySection";
import { LaunchFormCard } from "@/client/features/audit/launch/LaunchFormCard";
import { SettingsModal } from "@/client/features/audit/launch/SettingsModal";
import { useLaunchController } from "@/client/features/audit/launch/useLaunchController";
export function LaunchView({
@ -19,26 +18,14 @@ export function LaunchView({
<LaunchFormCard
launchForm={controller.launchForm}
settingsForm={controller.settingsForm}
state={controller.state}
setState={controller.setState}
isPending={controller.startMutation.isPending}
onSubmit={controller.handleSubmit}
onOpenSettings={controller.openSettings}
onRunPsiToggle={controller.onRunPsiToggle}
onRunLighthouseToggle={controller.onRunLighthouseToggle}
commitMaxPagesInput={controller.commitMaxPagesInput}
/>
{controller.state.isSettingsOpen && (
<SettingsModal
settingsForm={controller.settingsForm}
state={controller.state}
setState={controller.setState}
onClear={controller.clearPsiKey}
onSave={controller.saveSettings}
/>
)}
<AuditHistorySection
history={controller.historyQuery.data ?? []}
isLoading={controller.historyQuery.isLoading}

View File

@ -1,128 +0,0 @@
import type {
LaunchState,
SettingsFormApi,
} from "@/client/features/audit/launch/types";
export function SettingsModal({
settingsForm,
state,
setState,
onClear,
onSave,
}: {
settingsForm: SettingsFormApi;
state: LaunchState;
setState: React.Dispatch<React.SetStateAction<LaunchState>>;
onClear: () => void;
onSave: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4">
<div className="card w-full max-w-lg bg-base-100 border border-base-300 shadow-xl">
<div className="card-body gap-4">
<div className="flex items-center justify-between">
<h3 className="card-title text-base">Audit Settings</h3>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() =>
setState((prev) => ({ ...prev, isSettingsOpen: false }))
}
>
Close
</button>
</div>
<div className="space-y-2">
<label className="text-sm text-base-content/70">
Google PageSpeed Insights API Key
</label>
<div className="flex gap-2">
<settingsForm.Field name="psiApiKey">
{(field) => (
<input
type={state.showPsiKey ? "text" : "password"}
className="input input-bordered flex-1"
placeholder="Google API key"
value={field.state.value}
onChange={(event) => {
field.handleChange(event.target.value);
if (state.settingsError || state.psiRequirementError) {
setState((prev) => ({
...prev,
settingsError: null,
psiRequirementError: null,
}));
}
}}
/>
)}
</settingsForm.Field>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() =>
setState((prev) => ({
...prev,
showPsiKey: !prev.showPsiKey,
}))
}
>
{state.showPsiKey ? "Hide" : "Show"}
</button>
</div>
<p className="text-xs text-base-content/50">
Stored on this project and reused by PSI and Site Audit. Required
to run PSI checks in audits.
</p>
<PsiKeyHelp />
{state.settingsError ? (
<p className="text-sm text-error">{state.settingsError}</p>
) : null}
</div>
<div className="flex items-center justify-between">
<button
type="button"
className="btn btn-ghost btn-sm text-error"
onClick={onClear}
>
Clear key
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={onSave}
>
Save settings
</button>
</div>
</div>
</div>
</div>
);
}
function PsiKeyHelp() {
return (
<div className="rounded-md border border-base-300 bg-base-200/30 p-3 text-xs text-base-content/70 space-y-1.5">
<p className="font-medium text-base-content/80">Need a PSI key?</p>
<ol className="list-decimal list-inside space-y-1">
<li>
Open{" "}
<a
className="link link-primary"
href="https://developers.google.com/speed/docs/insights/v5/get-started"
target="_blank"
rel="noopener noreferrer"
>
PageSpeed Insights getting started
</a>{" "}
and click "Get a key".
</li>
<li>Create any Google Cloud project (for example: Open SEO).</li>
<li>Paste the key here and save.</li>
</ol>
</div>
);
}

View File

@ -1,12 +1,8 @@
import { useForm } from "@tanstack/react-form";
export type LaunchState = {
isSettingsOpen: boolean;
showPsiKey: boolean;
urlError: string | null;
psiRequirementError: string | null;
startError: string | null;
settingsError: string | null;
};
export const MIN_PAGES = 10;
@ -17,15 +13,10 @@ export function useLaunchForm() {
defaultValues: {
url: "",
maxPagesInput: "50",
runPsi: false,
psiMode: "auto" as "auto" | "all",
runLighthouse: false,
lighthouseMode: "auto" as "auto" | "all",
},
});
}
export function useSettingsForm() {
return useForm({ defaultValues: { psiApiKey: "" } });
}
export type LaunchFormApi = ReturnType<typeof useLaunchForm>;
export type SettingsFormApi = ReturnType<typeof useSettingsForm>;

View File

@ -1,4 +1,4 @@
import { useEffect, useState, type FormEvent } from "react";
import { useState, type FormEvent } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@ -6,16 +6,10 @@ import {
getAuditHistory,
startAudit,
} from "@/serverFunctions/audit";
import {
clearProjectPsiApiKey,
getProjectPsiApiKey,
saveProjectPsiApiKey,
} from "@/serverFunctions/psi";
import {
MAX_PAGES_LIMIT,
MIN_PAGES,
useLaunchForm,
useSettingsForm,
type LaunchState,
} from "@/client/features/audit/launch/types";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
@ -28,34 +22,20 @@ export function useLaunchController({
onAuditStarted: (auditId: string) => void;
}) {
const launchForm = useLaunchForm();
const settingsForm = useSettingsForm();
const [state, setState] = useState<LaunchState>({
isSettingsOpen: false,
showPsiKey: false,
urlError: null,
psiRequirementError: null,
startError: null,
settingsError: null,
});
const historyQuery = useQuery({
queryKey: ["audit-history", projectId],
queryFn: () => getAuditHistory({ data: { projectId } }),
});
const keyQuery = useQuery({
queryKey: ["projectPsiApiKey", projectId],
queryFn: () => getProjectPsiApiKey({ data: { projectId } }),
});
const { startMutation, deleteMutation, saveKeyMutation, clearKeyMutation } =
useLaunchMutations({
const { startMutation, deleteMutation } = useLaunchMutations({
projectId,
historyRefetch: historyQuery.refetch,
keyRefetch: keyQuery.refetch,
clearPsiApiKeyField: () => settingsForm.setFieldValue("psiApiKey", ""),
});
useSyncPsiKeyField(keyQuery.data?.apiKey, settingsForm);
const applyMaxPages = (value: number) => {
const safeValue = Number.isFinite(value)
? Math.max(MIN_PAGES, Math.min(MAX_PAGES_LIMIT, Math.round(value)))
@ -72,20 +52,13 @@ export function useLaunchController({
const handleStart = () => {
const launchValues = launchForm.state.values;
const settingsValues = settingsForm.state.values;
const effectiveMaxPages = commitMaxPagesInput();
setState((prev) => ({ ...prev, startError: null }));
if (!launchValues.url.trim())
if (!launchValues.url.trim()) {
return setState((prev) => ({ ...prev, urlError: "Please enter a URL." }));
if (launchValues.runPsi && !settingsValues.psiApiKey.trim()) {
return setState((prev) => ({
...prev,
psiRequirementError:
"Set a Google PageSpeed Insights API key before running PSI checks.",
isSettingsOpen: true,
}));
}
if (effectiveMaxPages > 500) {
const confirmed = window.confirm(
`You are about to crawl ${effectiveMaxPages.toLocaleString()} pages. This is okay, but it may take a while. Continue?`,
@ -98,19 +71,13 @@ export function useLaunchController({
projectId,
startUrl: launchValues.url,
maxPages: effectiveMaxPages,
psiStrategy: launchValues.runPsi ? launchValues.psiMode : "none",
psiApiKey: launchValues.runPsi
? settingsValues.psiApiKey || undefined
: undefined,
lighthouseStrategy: launchValues.runLighthouse
? launchValues.lighthouseMode
: "none",
},
{
onSuccess: (result) => {
setState((prev) => ({
...prev,
urlError: null,
psiRequirementError: null,
startError: null,
}));
setState({ urlError: null, startError: null });
toast.success("Audit started!");
onAuditStarted(result.auditId);
},
@ -126,7 +93,6 @@ export function useLaunchController({
return {
launchForm,
settingsForm,
state,
setState,
historyQuery,
@ -136,45 +102,25 @@ export function useLaunchController({
event.preventDefault();
handleStart();
},
openSettings: () => setState((prev) => ({ ...prev, isSettingsOpen: true })),
onRunPsiToggle: (checked: boolean) =>
handleRunPsiToggle(checked, launchForm, settingsForm, setState),
saveSettings: () =>
handleSaveSettings(settingsForm, setState, saveKeyMutation.mutate),
clearPsiKey: () => clearKeyMutation.mutate(),
onRunLighthouseToggle: (checked: boolean) =>
handleRunLighthouseToggle(checked, launchForm),
deleteAudit: (auditId: string) => deleteMutation.mutate(auditId),
};
}
function useSyncPsiKeyField(
apiKey: string | null | undefined,
settingsForm: ReturnType<typeof useSettingsForm>,
) {
useEffect(() => {
if (apiKey) {
settingsForm.setFieldValue("psiApiKey", apiKey);
}
}, [apiKey, settingsForm]);
}
function useLaunchMutations({
projectId,
historyRefetch,
keyRefetch,
clearPsiApiKeyField,
}: {
projectId: string;
historyRefetch: () => Promise<unknown>;
keyRefetch: () => Promise<unknown>;
clearPsiApiKeyField: () => void;
}) {
const startMutation = useMutation({
mutationFn: (data: {
projectId: string;
startUrl: string;
maxPages: number;
psiStrategy: "auto" | "all" | "none";
psiApiKey?: string;
lighthouseStrategy: "auto" | "all" | "none";
}) => startAudit({ data }),
});
@ -187,67 +133,12 @@ function useLaunchMutations({
},
});
const saveKeyMutation = useMutation({
mutationFn: (apiKey: string) =>
saveProjectPsiApiKey({ data: { projectId, apiKey } }),
onSuccess: async () => {
toast.success("PSI API key saved for this project");
await keyRefetch();
},
});
const clearKeyMutation = useMutation({
mutationFn: () => clearProjectPsiApiKey({ data: { projectId } }),
onSuccess: async () => {
clearPsiApiKeyField();
toast.success("PSI API key cleared");
await keyRefetch();
},
});
return { startMutation, deleteMutation, saveKeyMutation, clearKeyMutation };
return { startMutation, deleteMutation };
}
function handleRunPsiToggle(
function handleRunLighthouseToggle(
checked: boolean,
launchForm: ReturnType<typeof useLaunchForm>,
settingsForm: ReturnType<typeof useSettingsForm>,
setState: React.Dispatch<React.SetStateAction<LaunchState>>,
) {
if (!checked) {
setState((prev) => ({ ...prev, psiRequirementError: null }));
launchForm.setFieldValue("runPsi", false);
return;
}
if (!settingsForm.state.values.psiApiKey.trim()) {
setState((prev) => ({ ...prev, isSettingsOpen: true }));
return;
}
launchForm.setFieldValue("runPsi", true);
}
function handleSaveSettings(
settingsForm: ReturnType<typeof useSettingsForm>,
setState: React.Dispatch<React.SetStateAction<LaunchState>>,
save: (apiKey: string) => void,
) {
const trimmed = settingsForm.state.values.psiApiKey.trim();
if (!trimmed) {
setState((prev) => ({
...prev,
settingsError: "Please enter an API key.",
}));
return;
}
setState((prev) => ({
...prev,
settingsError: null,
psiRequirementError: null,
showPsiKey: false,
isSettingsOpen: false,
}));
save(trimmed);
launchForm.setFieldValue("runLighthouse", checked);
}

View File

@ -2,10 +2,35 @@ import { ChevronDown, Download, ExternalLink } from "lucide-react";
import {
extractPathname,
HttpStatusBadge,
PsiScoreBadge,
LighthouseScoreBadge,
} from "@/client/features/audit/shared";
import type { AuditResultsData } from "@/client/features/audit/results/types";
type LighthouseFailureFields = {
errorMessage: string | null;
performanceScore: number | null;
accessibilityScore: number | null;
bestPracticesScore: number | null;
seoScore: number | null;
};
function hasMissingLighthouseScores(row: LighthouseFailureFields) {
return (
row.performanceScore == null &&
row.accessibilityScore == null &&
row.bestPracticesScore == null &&
row.seoScore == null
);
}
export function isLighthouseFailure(row: LighthouseFailureFields) {
return !!row.errorMessage || hasMissingLighthouseScores(row);
}
function getLighthouseFailureMessage(row: LighthouseFailureFields) {
return row.errorMessage ?? "Lighthouse returned no category scores";
}
export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
return (
<div className="overflow-x-auto">
@ -22,7 +47,7 @@ export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
</tr>
</thead>
<tbody>
{pages.map((page) => (
{pages.map((page: AuditResultsData["pages"][number]) => (
<tr key={page.id}>
<td className="max-w-[200px] truncate">
<a
@ -66,12 +91,14 @@ export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
}
export function PerformanceTable({
auditId,
projectId,
psi,
lighthouse,
pages,
}: {
auditId: string;
projectId: string;
psi: AuditResultsData["psi"];
lighthouse: AuditResultsData["lighthouse"];
pages: AuditResultsData["pages"];
}) {
return (
@ -93,12 +120,16 @@ export function PerformanceTable({
</tr>
</thead>
<tbody>
{psi.map((result) => (
{lighthouse.map((result: AuditResultsData["lighthouse"][number]) => (
<PerformanceRow
key={result.id}
auditId={auditId}
projectId={projectId}
result={result}
page={pages.find((candidate) => candidate.id === result.pageId)}
page={pages.find(
(candidate: AuditResultsData["pages"][number]) =>
candidate.id === result.pageId,
)}
/>
))}
</tbody>
@ -108,15 +139,18 @@ export function PerformanceTable({
}
function PerformanceRow({
auditId,
projectId,
result,
page,
}: {
auditId: string;
projectId: string;
result: AuditResultsData["psi"][number];
result: AuditResultsData["lighthouse"][number];
page: AuditResultsData["pages"][number] | undefined;
}) {
const isFailed = !!result.errorMessage;
const isFailed = isLighthouseFailure(result);
const failureMessage = getLighthouseFailureMessage(result);
return (
<tr>
@ -128,7 +162,7 @@ function PerformanceRow({
{isFailed ? (
<span
className="badge badge-error badge-outline text-xs"
title={result.errorMessage ?? "PSI check failed"}
title={failureMessage}
>
failed
</span>
@ -137,13 +171,13 @@ function PerformanceRow({
)}
</td>
<td>
<PsiScoreBadge score={result.performanceScore} />
<LighthouseScoreBadge score={result.performanceScore} />
</td>
<td>
<PsiScoreBadge score={result.accessibilityScore} />
<LighthouseScoreBadge score={result.accessibilityScore} />
</td>
<td>
<PsiScoreBadge score={result.seoScore} />
<LighthouseScoreBadge score={result.seoScore} />
</td>
<td className="text-xs">
{result.lcpMs ? `${(result.lcpMs / 1000).toFixed(1)}s` : "-"}
@ -158,10 +192,10 @@ function PerformanceRow({
{result.ttfbMs ? `${Math.round(result.ttfbMs)}ms` : "-"}
</td>
<td>
{result.r2Key ? (
{result.r2Key && !isFailed ? (
<a
className="btn btn-primary btn-xs"
href={`/p/${projectId}/audit/issues/${result.id}?category=performance`}
href={`/p/${projectId}/audit/issues/${result.id}?auditId=${auditId}&category=performance`}
>
View issues
</a>

View File

@ -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 (
<>
<StatsGrid
pagesCrawled={audit.pagesCrawled}
totalPages={pages.length}
totalPsi={psi.length}
totalLighthouse={lighthouse.length}
averageResponseMs={stats.averageResponseMs}
psiSummary={stats.psiSummary}
lighthouseSummary={stats.lighthouseSummary}
/>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<ResultsHeader
pageCount={pages.length}
psiCount={psi.length}
lighthouseCount={lighthouse.length}
hasPerformanceTab={hasPerformanceTab}
activeTab={activeTab}
setSearchParams={setSearchParams}
onExport={(format) => {
if (activeTab === "performance") {
exportPerformance(psi, pages, format);
exportPerformance(lighthouse, pages, format);
return;
}
exportPages(pages, format);
@ -57,8 +58,13 @@ export function ResultsView({
/>
{activeTab === "pages" && <PagesTable pages={pages} />}
{activeTab === "performance" && psi.length > 0 && (
<PerformanceTable projectId={projectId} psi={psi} pages={pages} />
{activeTab === "performance" && lighthouse.length > 0 && (
<PerformanceTable
auditId={audit.id}
projectId={projectId}
lighthouse={lighthouse}
pages={pages}
/>
)}
</div>
</div>
@ -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})
</button>
</div>
) : (
@ -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({
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Pages Crawled" value={String(pagesCrawled)} />
<StatCard label="Total URLs" value={String(totalPages)} />
<StatCard label="PSI Tests" value={String(totalPsi)} />
<StatCard label="Lighthouse Tests" value={String(totalLighthouse)} />
<StatCard label="Avg Response" value={`${averageResponseMs}ms`} />
{totalPsi > 0 && (
{totalLighthouse > 0 && (
<>
<StatCard
label="Avg PSI Perf"
label="Avg Lighthouse Perf"
value={
psiSummary.avgPerformance == null
lighthouseSummary.avgPerformance == null
? "-"
: String(psiSummary.avgPerformance)
: String(lighthouseSummary.avgPerformance)
}
className={scoreClass(psiSummary.avgPerformance)}
className={scoreClass(lighthouseSummary.avgPerformance)}
/>
<StatCard
label="Avg PSI SEO"
value={psiSummary.avgSeo == null ? "-" : String(psiSummary.avgSeo)}
className={scoreClass(psiSummary.avgSeo)}
/>
<StatCard
label="Avg PSI A11y"
label="Avg Lighthouse SEO"
value={
psiSummary.avgAccessibility == null
lighthouseSummary.avgSeo == null
? "-"
: String(psiSummary.avgAccessibility)
: String(lighthouseSummary.avgSeo)
}
className={scoreClass(psiSummary.avgAccessibility)}
className={scoreClass(lighthouseSummary.avgSeo)}
/>
<StatCard
label="PSI Failures"
value={String(psiSummary.failed)}
className={psiSummary.failed > 0 ? "text-error" : "text-success"}
label="Avg Lighthouse A11y"
value={
lighthouseSummary.avgAccessibility == null
? "-"
: String(lighthouseSummary.avgAccessibility)
}
className={scoreClass(lighthouseSummary.avgAccessibility)}
/>
<StatCard
label="Lighthouse Failures"
value={String(lighthouseSummary.failed)}
className={
lighthouseSummary.failed > 0 ? "text-error" : "text-success"
}
/>
</>
)}

View File

@ -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,12 +60,16 @@ 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);
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,
@ -77,7 +81,8 @@ export function exportPerformance(
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,

View File

@ -70,7 +70,7 @@ export function HttpStatusBadge({ code }: { code: number | null }) {
return <span className="badge badge-error badge-sm">{code}</span>;
}
export function PsiScoreBadge({ score }: { score: number | null }) {
export function LighthouseScoreBadge({ score }: { score: number | null }) {
if (score == null) {
return <span className="text-xs text-base-content/40">-</span>;
}

View File

@ -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 (
<>
<tr
className={`hover:bg-base-200/50 transition-colors ${hasDetails ? "cursor-pointer" : ""}`}
onClick={() => hasDetails && setOpen(!open)}
>
<td className="py-3 pl-4 pr-2">
{hasDetails ? (
<ChevronRight
className={`size-3.5 text-base-content/40 transition-transform ${open ? "rotate-90" : ""}`}
/>
) : null}
</td>
<td className="py-3 pr-3">
<span
className={`badge badge-sm border ${severityBadgeClass(issue.severity)} gap-1`}
>
{severityIcon(issue.severity)}
{issue.severity}
</span>
</td>
<td className="py-3 pr-3">
<div>
<p className="font-medium text-sm leading-snug">{issue.title}</p>
{issue.displayValue ? (
<p className="text-xs text-base-content/50 mt-0.5">
{issue.displayValue}
</p>
) : null}
</div>
</td>
<td className="py-3 pr-3 hidden sm:table-cell">
<span className="text-xs text-base-content/50">{issue.category}</span>
</td>
<td className="py-3 pr-3 hidden md:table-cell text-right">
{issue.impactMs != null || issue.impactBytes != null ? (
<span className="text-xs tabular-nums text-base-content/50">
{issue.impactMs ? formatMs(issue.impactMs) : null}
{issue.impactMs && issue.impactBytes ? " / " : null}
{issue.impactBytes ? formatBytes(issue.impactBytes) : null}
</span>
) : null}
</td>
<td className="py-3 pr-4 text-right">
{issue.score != null ? (
<span className="text-xs tabular-nums text-base-content/50">
{issue.score}
</span>
) : null}
</td>
</tr>
{open ? (
<tr className="!bg-transparent">
<td colSpan={6} className="pb-4 pt-2 pl-[8.5rem] pr-4">
<div className="space-y-3">
{issue.description ? (
<div className="text-sm text-base-content/70 leading-relaxed">
{renderInlineMarkdown(issue.description)}
</div>
) : null}
{issue.items.length > 0 ? (
<details className="text-sm">
<summary className="cursor-pointer font-medium text-base-content/60 text-xs">
Affected items ({issue.items.length})
</summary>
<div className="mt-2 space-y-1.5">
{issue.items.map((item, itemIndex) => (
<pre
key={`${issue.auditKey}-${itemIndex}`}
className="bg-base-200/60 p-2 rounded overflow-x-auto text-xs leading-relaxed"
>
{item}
</pre>
))}
</div>
</details>
) : null}
</div>
</td>
</tr>
) : 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(
<a
key={`${href}-${index}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="link link-primary inline-flex items-center gap-1"
>
{label}
<ExternalLink className="size-3" />
</a>,
);
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 <FileWarning className="size-3" />;
if (severity === "warning") return <TriangleAlert className="size-3" />;
return <Info className="size-3" />;
}

View File

@ -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({
<div className="card bg-base-100 border border-base-300">
<div className="card-body py-5 gap-4">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">PSI Issues</h1>
<h1 className="text-2xl font-semibold">Lighthouse Issues</h1>
<p className="text-sm text-base-content/70 break-all">
{finalUrl ?? "Loading URL..."}
</p>
</div>
<LighthouseIssuesSummary scores={scores} metrics={metrics} />
<div className="flex flex-wrap gap-2 text-xs">
<span className="badge border border-error/30 bg-error/10 text-error/80 gap-1">
<FileWarning className="size-3" />
@ -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<CategoryTab, number>;
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 (
<div className="dropdown dropdown-end">
@ -201,21 +209,23 @@ function ExportMenu({
<li>
<button
disabled={isBusy}
onClick={() => onCopy({ mode: "issues" }, "Copied all issues")}
onClick={() =>
onCopy({ mode: "issues" }, "Copied all actionable issues")
}
>
<Copy className="size-4" />
Copy all issues
Copy all actionable issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
onCopy({ mode: "full" }, "Copied full Lighthouse report")
onCopy({ mode: "full" }, "Copied saved Lighthouse payload")
}
>
<Copy className="size-4" />
Copy full Lighthouse report
Copy saved Lighthouse payload
</button>
</li>
<li className="menu-title">
@ -234,12 +244,12 @@ function ExportMenu({
disabled={isBusy}
onClick={() => onExport({ mode: "issues" })}
>
Download all issues
Download all actionable issues
</button>
</li>
<li>
<button disabled={isBusy} onClick={() => onExport({ mode: "full" })}>
Download full Lighthouse report
Download saved Lighthouse payload
</button>
</li>
<li className="menu-title">
@ -258,7 +268,7 @@ function ExportMenu({
disabled={!allIssues.length}
onClick={() => onExportCsv(allIssues, "all")}
>
Download all issues
Download all actionable issues
</button>
</li>
</ul>
@ -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 <p className="text-sm text-base-content/60">Loading issues...</p>;
@ -279,84 +291,40 @@ export function PsiIssueList({
if (!issues.length) {
return (
<p className="text-sm text-base-content/60">
No unresolved issues for this category.
{emptyMessage ?? "No actionable issues for this category."}
</p>
);
}
return (
<div className="space-y-3">
{issues.map((issue) => (
<PsiIssueCard
key={`${issue.category}-${issue.auditKey}`}
<table className="table table-sm w-full table-fixed">
<colgroup>
<col className="w-8" />
<col className="w-24" />
<col />
<col className="w-28 hidden sm:table-column" />
<col className="w-28 hidden md:table-column" />
<col className="w-14" />
</colgroup>
<thead>
<tr className="text-xs text-base-content/50 uppercase tracking-wide border-b border-base-300">
<th />
<th className="font-medium">Severity</th>
<th className="font-medium">Issue</th>
<th className="font-medium hidden sm:table-cell">Category</th>
<th className="font-medium hidden md:table-cell text-right">
Impact
</th>
<th className="font-medium text-right">Score</th>
</tr>
</thead>
<tbody className="divide-y divide-base-300/60">
{issues.map((issue, issueIndex) => (
<LighthouseIssueRow
key={`${issue.category}-${issue.auditKey}-${issueIndex}`}
issue={issue}
/>
))}
</div>
);
}
function PsiIssueCard({ issue }: { issue: PsiIssue }) {
return (
<div className="card bg-base-200/30 border border-base-300">
<div className="card-body p-5 gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="badge badge-outline">{issue.category}</span>
<span
className={`badge border ${severityBadgeClass(issue.severity)} gap-1`}
>
{severityIcon(issue.severity)}
{issue.severity}
</span>
{issue.score != null ? (
<div
className="tooltip tooltip-top"
data-tip="Lighthouse score from 0-100 for this audit. Lower means larger opportunity for improvement."
>
<span className="badge badge-ghost cursor-help">
Score {issue.score}
</span>
</div>
) : null}
</div>
{issue.impactMs != null || issue.impactBytes != null ? (
<span className="text-xs text-base-content/60">
Impact {issue.impactMs ?? 0}ms / {issue.impactBytes ?? 0} bytes
</span>
) : null}
</div>
<p className="font-semibold leading-tight">{issue.title}</p>
{issue.displayValue ? (
<p className="text-sm text-base-content/70">{issue.displayValue}</p>
) : null}
{issue.description ? (
<div className="text-sm text-base-content/80 leading-relaxed">
{renderInlineMarkdown(issue.description)}
</div>
) : null}
{issue.items.length > 0 ? (
<details className="text-sm bg-base-100 rounded-box border border-base-300/80 px-3 py-2">
<summary className="cursor-pointer font-medium text-base-content/75">
Affected items ({issue.items.length})
</summary>
<div className="mt-2 space-y-2">
{issue.items.map((item) => (
<pre
key={`${issue.auditKey}-${item}`}
className="bg-base-200/60 p-2 rounded-box overflow-x-auto text-xs"
>
{item}
</pre>
))}
</div>
</details>
) : null}
</div>
</div>
</tbody>
</table>
);
}

View File

@ -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 (
<div className="px-4 py-3 md:px-6 md:py-4 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-5xl space-y-4">
<PsiIssuesHeader
<LighthouseIssuesHeader
backLabel={backLabel}
onBack={onBack}
scannedAt={issuesQuery.data?.createdAt}
finalUrl={issuesQuery.data?.finalUrl}
scores={issuesQuery.data?.scores}
metrics={issuesQuery.data?.metrics}
severityCounts={severityCounts}
/>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<PsiIssuesToolbar
{issuesQuery.isError ? (
<div className="alert alert-error">
<AlertCircle className="size-4" />
<span>{issuesErrorMessage}</span>
</div>
) : null}
{showsLegacyPayloadNotice ? (
<div className="alert alert-warning">
<TriangleAlert className="size-4" />
<span>
This Lighthouse run was stored before issue details were
preserved. Re-run the audit to see category counts and issue
cards.
</span>
</div>
) : null}
<LighthouseIssuesToolbar
category={category}
categoryCounts={categoryCounts}
selectedCategoryLabel={selectedCategoryLabel}
@ -107,9 +130,10 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
}}
onExportCsv={runExportCsv}
/>
<PsiIssueList
<LighthouseIssueList
issues={visibleIssues}
isLoading={issuesQuery.isLoading}
emptyMessage={emptyMessage}
/>
</div>
</div>
@ -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<CategoryTab, number> {
function getCategoryCounts(
allIssues: LighthouseIssue[],
): Record<CategoryTab, number> {
return categoryTabs.reduce<Record<CategoryTab, number>>(
(acc, tab) => {
if (tab === "all") {
@ -201,7 +230,7 @@ function getCategoryCounts(allIssues: PsiIssue[]): Record<CategoryTab, number> {
);
}
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,

View File

@ -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 ? (
<div className="grid grid-cols-4 gap-3">
<ScoreGauge label="Performance" score={scores.performance} />
<ScoreGauge label="Accessibility" score={scores.accessibility} />
<ScoreGauge label="Best Practices" score={scores["best-practices"]} />
<ScoreGauge label="SEO" score={scores.seo} />
</div>
) : null}
{metricItems.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-1 rounded-box border border-base-300 bg-base-200/25 px-4 py-3">
{metricItems.map((metric) => (
<div
key={metric.label}
className="flex items-baseline justify-between gap-2 py-1"
>
<span className="text-xs text-base-content/50 uppercase tracking-wide">
{metric.label}
</span>
<span className="text-sm font-semibold tabular-nums text-base-content">
{metric.value}
</span>
</div>
))}
</div>
) : 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 (
<div className="flex flex-col items-center gap-1.5 py-2">
<div className="relative size-16">
<svg viewBox="0 0 64 64" className="size-full -rotate-90">
<circle
cx="32"
cy="32"
r={radius}
fill="none"
strokeWidth="4"
className="stroke-base-300/60"
/>
{score != null ? (
<circle
cx="32"
cy="32"
r={radius}
fill="none"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray={`${progress} ${circumference}`}
className={scoreStrokeColor(score)}
/>
) : null}
</svg>
<span
className={`absolute inset-0 flex items-center justify-center text-lg font-bold ${scoreColor(score)}`}
>
{score ?? "-"}
</span>
</div>
<span className="text-[11px] text-base-content/55 text-center leading-tight">
{label}
</span>
</div>
);
}
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,
);
}

View File

@ -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<typeof lighthouseAuditExportSchema>,
"projectId" | "resultId"
>;
type LighthouseIssuesResponse = Awaited<
ReturnType<typeof getAuditLighthouseIssues>
>;
export type LighthouseIssue = LighthouseIssuesResponse["issues"][number];
export type LighthouseScores = NonNullable<LighthouseIssuesResponse["scores"]>;
export type LighthouseMetrics = NonNullable<
LighthouseIssuesResponse["metrics"]
>;

View File

@ -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);
}

View File

@ -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<CategoryTab, "all">;
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[];
};

View File

@ -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(
<a
key={`${href}-${index}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="link link-primary inline-flex items-center gap-1"
>
{label}
<ExternalLink className="size-3" />
</a>,
);
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 <FileWarning className="size-3" />;
if (severity === "warning") return <TriangleAlert className="size-3" />;
return <Info className="size-3" />;
}

View File

@ -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)],
);

View File

@ -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({
<div className="flex items-center justify-between">
<h2 className="font-medium flex items-center gap-2">
<Loader2 className="size-4 animate-spin text-primary" />
{isPsiPhase ? "Running PSI checks" : "Crawling pages"}
{isLighthousePhase
? "Running Lighthouse checks"
: "Crawling pages"}
</h2>
<span className="badge badge-ghost badge-sm">{phaseLabel}</span>
</div>
@ -252,10 +256,12 @@ function ProgressCard({
/>
<div className="flex items-center justify-between text-sm">
{isPsiPhase ? (
{isLighthousePhase ? (
<span>
{psiDone} / {status.psiTotal} checks
{status.psiFailed > 0 ? ` (${status.psiFailed} failed)` : ""}
{lighthouseDone} / {status.lighthouseTotal} checks
{status.lighthouseFailed > 0
? ` (${status.lighthouseFailed} failed)`
: ""}
</span>
) : (
<span>

View File

@ -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 (
<PsiIssuesScreen
<LighthouseIssuesScreen
projectId={projectId}
resultId={resultId}
category={category}
@ -24,6 +24,7 @@ function AuditIssuesPage() {
void navigate({
to: "/p/$projectId/audit",
params: { projectId },
search: auditId ? { auditId } : undefined,
})
}
onCategoryChange={(next) =>

View File

@ -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<typeof db.batch>[0][number];
async function executeInBatches<T>(
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,131 +125,69 @@ 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({
await executeInBatches(lighthouseResults, (result) =>
db.insert(auditLighthouseResults).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,
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,
}),
);
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]);
}
}
}
// ─── Read ────────────────────────────────────────────────────────────────────
async function getAuditForProject(auditId: string, projectId: string) {
return db.query.audits.findFirst({
@ -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;
async function getAuditResultsForProject(auditId: string, projectId: string) {
const audit = await getAuditForProject(auditId, projectId);
if (!audit) {
return { audit: null, pages: [], lighthouse: [] };
}
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 psi = await db.query.auditPsiResults.findFirst({
where: eq(auditPsiResults.id, input.psiResultId),
const lighthouse = await db.query.auditLighthouseResults.findFirst({
where: eq(auditLighthouseResults.id, input.lighthouseResultId),
});
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");
if (!lighthouse) {
return null;
}
const page = await db.query.auditPages.findFirst({
where: eq(auditPages.id, psi.pageId),
});
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;

View File

@ -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);
}

View File

@ -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);
});
});

View File

@ -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,
};
}

View File

@ -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",
}),
]);
});
});

View File

@ -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;

View File

@ -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<AuditPsiTarget> {
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;

View File

@ -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<Record<string, unknown>>;
};
};
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, unknown>): string {
const preferredKeys = [
"url",
"source",
"nodeLabel",
"snippet",
"totalBytes",
"wastedBytes",
"wastedMs",
"label",
"value",
];
const output: Record<string, unknown> = {};
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<string, LighthouseAudit> =
parsedPayload.data.lighthouseResult.audits;
const categories: Record<string, LighthouseCategory> =
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;

View File

@ -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,
),
};
}

View File

@ -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<LighthouseFetchResult> {
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<LighthouseResult> {
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<string>();
// 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<string, LighthouseSamplePage>();
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);
}

View File

@ -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<PsiResult> {
// 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<string>();
// 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<string, PsiSamplePage>();
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);
}

View File

@ -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;
}

View File

@ -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<StoredLighthousePayload>(customer, () =>
fetchDataforseoLighthouseResultRaw(input),
);
},
},
} as const;
}

View File

@ -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<DataforseoApiResponse<StoredLighthousePayload>> {
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,
},
};
}

View File

@ -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("<root>");
});
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();
});
});

View File

@ -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(".") : "<root>";
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<string, RawLighthouseCategory> =
result.categories ?? {};
const audits: Record<string, RawLighthouseAudit> = 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;
}

View File

@ -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,
),
};
}

View File

@ -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}',
);
});
});

View File

@ -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<Record<string, unknown>>;
};
};
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, unknown>): string {
const preferredKeys = [
"url",
"source",
"nodeLabel",
"snippet",
"totalBytes",
"wastedBytes",
"wastedMs",
"label",
"value",
];
const output: Record<string, unknown> = {};
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<string, RawLighthouseAudit>;
categories: Record<string, RawLighthouseCategory>;
}) {
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<string, RawLighthouseAudit>;
}): 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"]),
};
}

View File

@ -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<Env, AuditParams> {
async run(event: WorkflowEvent<AuditParams>, 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<Env, AuditParams> {
await runAuditPhases(step, {
auditId,
workflowInstanceId: event.instanceId,
billingCustomer,
projectId,
startUrl,
config,

View File

@ -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<PsiResult> {
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,

View File

@ -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;

View File

@ -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<PsiResult[]> {
const { auditId, workflowInstanceId, projectId, startUrl, config, allPages } =
params;
if (config.psiStrategy === "none" || !config.psiApiKey) return [];
params: LighthousePhaseParams,
): Promise<LighthouseResult[]> {
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 () => {
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, {
psiCompleted,
psiFailed,
});
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,

View File

@ -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 }) => {

View File

@ -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,
});
});

View File

@ -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,
);
});

View File

@ -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,
});
});

14
src/shared/lighthouse.ts Normal file
View File

@ -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];

View File

@ -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({

View File

@ -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"),
});

View File

@ -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"),
});