Stop cloning the raw Lighthouse report through Zod (#528)

This commit is contained in:
Ben Senescu 2026-08-26 10:11:14 -04:00 committed by GitHub
parent 94b730124b
commit 8752269149
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 102 additions and 107 deletions

View File

@ -196,55 +196,71 @@ describe("parseDataforseoLighthousePayload", () => {
).toThrow("<root>"); ).toThrow("<root>");
}); });
it("accepts audits whose details.items is an object", () => { it("rejects a report whose audit fields are off-spec", () => {
expect(() => expect(() =>
parseDataforseoLighthousePayload( parseDataforseoLighthousePayload(
{ {
status_code: 20000, status_code: 20000,
status_message: "Ok.",
tasks: [ tasks: [
{ {
id: "task-1",
status_code: 20000, status_code: 20000,
status_message: "Ok.",
cost: 0.00425,
result: [ result: [
{ {
requestedUrl: "https://everyapp.dev/",
finalUrl: "https://everyapp.dev/",
lighthouseVersion: "12.2.0",
categories: { categories: {
performance: { performance: {
score: 0.54, score: 0.54,
auditRefs: [{ id: "document-latency-insight" }], auditRefs: [{ id: "unused-javascript" }],
}, },
accessibility: { score: 0.93, auditRefs: [] },
"best-practices": { score: 0.79, auditRefs: [] },
seo: { score: 0.92, auditRefs: [] },
}, },
audits: { audits: {
"document-latency-insight": { "unused-javascript": { title: 42, score: 0 },
title: "Document request latency",
description: "Latency insight.",
score: 0,
scoreDisplayMode: "informative",
details: {
items: {
latencyMs: 120,
},
},
},
}, },
}, },
], ],
}, },
], ],
}, },
{ { url: "https://everyapp.dev/", strategy: "mobile" },
url: "https://everyapp.dev/",
strategy: "mobile",
},
), ),
).not.toThrow(); ).toThrow("DataForSEO Lighthouse returned an invalid report");
});
it("reads audits whose details.items is a single object", () => {
const parsed = parseDataforseoLighthousePayload(
{
status_code: 20000,
tasks: [
{
status_code: 20000,
result: [
{
categories: {
performance: {
score: 0.54,
auditRefs: [{ id: "document-latency-insight" }],
},
},
audits: {
"document-latency-insight": {
title: "Document request latency",
score: 0,
scoreDisplayMode: "metricSavings",
details: { items: { url: "https://everyapp.dev/" } },
},
},
},
],
},
],
},
{ url: "https://everyapp.dev/", strategy: "mobile" },
);
expect(parsed.issues).toEqual([
expect.objectContaining({
auditKey: "document-latency-insight",
items: ['{"url":"https://everyapp.dev/"}'],
}),
]);
}); });
}); });

View File

@ -6,6 +6,7 @@ import {
type RawLighthouseCategory, type RawLighthouseCategory,
scoreToPercent, scoreToPercent,
type StoredLighthousePayload, type StoredLighthousePayload,
storedLighthousePayloadSchema,
} from "@/server/lib/lighthouseStoredPayload"; } from "@/server/lib/lighthouseStoredPayload";
export const requestCategories = [ export const requestCategories = [
@ -17,77 +18,32 @@ export const requestCategories = [
export type LighthouseStrategy = "mobile" | "desktop"; export type LighthouseStrategy = "mobile" | "desktop";
const lighthouseAuditItemsSchema = z const lighthouseResponseSchema = z.object({
.union([ requestedUrl: z.string().optional(),
z.array(z.record(z.string(), z.unknown())), finalUrl: z.string().optional(),
z.record(z.string(), z.unknown()), lighthouseVersion: z.string().optional(),
]) // Only the key map is copied here, so the multi-MB category/audit bodies stay
.transform((items) => (Array.isArray(items) ? items : [items])); // as the provider's own objects. Deep-parsing them cloned the whole report a
// second time and pushed the audit worker over its memory limit.
categories: z
.record(z.string(), z.custom<RawLighthouseCategory>())
.optional(),
audits: z.record(z.string(), z.custom<RawLighthouseAudit>()).optional(),
});
const lighthouseAuditSchema = z const dataforseoTaskSchema = z.object({
.object({ id: z.string().optional(),
score: z.number().nullable().optional(), cost: z.number().optional(),
displayValue: z.string().optional(), status_code: z.number().optional(),
numericValue: z.number().optional(), status_message: z.string().optional(),
title: z.string().optional(), result: z.array(lighthouseResponseSchema).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 const dataforseoLighthouseResponseSchema = z.object({
.object({ status_code: z.number().optional(),
score: z.number().nullable().optional(), status_message: z.string().optional(),
auditRefs: z tasks: z.array(dataforseoTaskSchema).optional(),
.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 { function summarizeZodIssues(error: z.ZodError, maxIssues = 3): string {
return error.issues return error.issues
@ -103,6 +59,10 @@ export function parseDataforseoLighthousePayload(
payload: unknown, payload: unknown,
input: { url: string; strategy: LighthouseStrategy }, input: { url: string; strategy: LighthouseStrategy },
): StoredLighthousePayload { ): StoredLighthousePayload {
// Only the envelope scalars are validated up front. The report is reduced
// straight into the compact stored payload, which is then validated in full
// below — the same fields the old whole-report schema checked, at kilobyte
// size instead of multi-megabyte.
const parsed = dataforseoLighthouseResponseSchema.safeParse(payload); const parsed = dataforseoLighthouseResponseSchema.safeParse(payload);
if (!parsed.success) { if (!parsed.success) {
throw new Error( throw new Error(
@ -131,9 +91,8 @@ export function parseDataforseoLighthousePayload(
} }
const fetchedAt = new Date().toISOString(); const fetchedAt = new Date().toISOString();
const categories: Record<string, RawLighthouseCategory> = const categories = result.categories ?? {};
result.categories ?? {}; const audits = result.audits ?? {};
const audits: Record<string, RawLighthouseAudit> = result.audits ?? {};
const issueReport = buildStoredLighthouseIssues({ audits, categories }); const issueReport = buildStoredLighthouseIssues({ audits, categories });
const metrics = buildStoredLighthouseMetrics({ audits }); const metrics = buildStoredLighthouseMetrics({ audits });
const storedPayload: StoredLighthousePayload = { const storedPayload: StoredLighthousePayload = {
@ -168,5 +127,15 @@ export function parseDataforseoLighthousePayload(
); );
} }
// Without this, an off-spec provider field (a numeric audit title, say) would
// be stored and then fail to parse on read, silently blanking the page's
// whole Lighthouse view instead of failing the check.
const validated = storedLighthousePayloadSchema.safeParse(storedPayload);
if (!validated.success) {
throw new Error(
`DataForSEO Lighthouse returned an invalid report: ${summarizeZodIssues(validated.error)}`,
);
}
return storedPayload; return storedPayload;
} }

View File

@ -11,7 +11,8 @@ export type RawLighthouseAudit = {
details?: { details?: {
overallSavingsMs?: number; overallSavingsMs?: number;
overallSavingsBytes?: number; overallSavingsBytes?: number;
items?: Array<Record<string, unknown>>; /** Newer "insight" audits report a single object instead of a list. */
items?: Array<Record<string, unknown>> | Record<string, unknown>;
}; };
}; };
@ -83,10 +84,14 @@ export type StoredLighthousePayload = z.infer<
typeof storedLighthousePayloadSchema typeof storedLighthousePayloadSchema
>; >;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function scoreToPercent( export function scoreToPercent(
score: number | null | undefined, score: number | null | undefined,
): number | null { ): number | null {
if (score == null || Number.isNaN(score)) return null; if (typeof score !== "number" || Number.isNaN(score)) return null;
return Math.round(score * 100); return Math.round(score * 100);
} }
@ -180,9 +185,10 @@ export function buildStoredLighthouseIssues(input: {
const issues: StoredLighthouseIssue[] = []; const issues: StoredLighthouseIssue[] = [];
for (const category of LIGHTHOUSE_CATEGORIES) { for (const category of LIGHTHOUSE_CATEGORIES) {
const refs = input.categories[category]?.auditRefs ?? []; const rawRefs = input.categories[category]?.auditRefs;
const refs = Array.isArray(rawRefs) ? rawRefs : [];
for (const ref of refs) { for (const ref of refs) {
const auditKey = ref.id; const auditKey = ref?.id;
if (!auditKey) continue; if (!auditKey) continue;
const audit = input.audits[auditKey]; const audit = input.audits[auditKey];
@ -212,9 +218,13 @@ export function buildStoredLighthouseIssues(input: {
typeof audit.details?.overallSavingsBytes === "number" typeof audit.details?.overallSavingsBytes === "number"
? audit.details.overallSavingsBytes ? audit.details.overallSavingsBytes
: null; : null;
const items = Array.isArray(audit.details?.items) const rawItems = audit.details?.items;
? audit.details.items.slice(0, 10).map(compactItem) const itemList = Array.isArray(rawItems)
: []; ? rawItems
: isRecord(rawItems)
? [rawItems]
: [];
const items = itemList.filter(isRecord).slice(0, 10).map(compactItem);
issues.push({ issues.push({
category, category,