From 87522691492e5a4b32e44d52981321e991bb5883 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:11:14 -0400 Subject: [PATCH] Stop cloning the raw Lighthouse report through Zod (#528) --- .../lib/dataforseoLighthousePayload.test.ts | 72 ++++++----- src/server/lib/dataforseoLighthousePayload.ts | 113 +++++++----------- src/server/lib/lighthouseStoredPayload.ts | 24 ++-- 3 files changed, 102 insertions(+), 107 deletions(-) diff --git a/src/server/lib/dataforseoLighthousePayload.test.ts b/src/server/lib/dataforseoLighthousePayload.test.ts index bbe0f1c..ba66f8b 100644 --- a/src/server/lib/dataforseoLighthousePayload.test.ts +++ b/src/server/lib/dataforseoLighthousePayload.test.ts @@ -196,55 +196,71 @@ describe("parseDataforseoLighthousePayload", () => { ).toThrow(""); }); - it("accepts audits whose details.items is an object", () => { + it("rejects a report whose audit fields are off-spec", () => { 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" }], + auditRefs: [{ id: "unused-javascript" }], }, - 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, - }, - }, - }, + "unused-javascript": { title: 42, score: 0 }, }, }, ], }, ], }, - { - 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/"}'], + }), + ]); }); }); diff --git a/src/server/lib/dataforseoLighthousePayload.ts b/src/server/lib/dataforseoLighthousePayload.ts index edacd1e..94034e6 100644 --- a/src/server/lib/dataforseoLighthousePayload.ts +++ b/src/server/lib/dataforseoLighthousePayload.ts @@ -6,6 +6,7 @@ import { type RawLighthouseCategory, scoreToPercent, type StoredLighthousePayload, + storedLighthousePayloadSchema, } from "@/server/lib/lighthouseStoredPayload"; export const requestCategories = [ @@ -17,77 +18,32 @@ export const requestCategories = [ 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 lighthouseResponseSchema = z.object({ + requestedUrl: z.string().optional(), + finalUrl: z.string().optional(), + lighthouseVersion: z.string().optional(), + // Only the key map is copied here, so the multi-MB category/audit bodies stay + // 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()) + .optional(), + audits: z.record(z.string(), z.custom()).optional(), +}); -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 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(), +}); -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(); +const dataforseoLighthouseResponseSchema = z.object({ + status_code: z.number().optional(), + status_message: z.string().optional(), + tasks: z.array(dataforseoTaskSchema).optional(), +}); function summarizeZodIssues(error: z.ZodError, maxIssues = 3): string { return error.issues @@ -103,6 +59,10 @@ export function parseDataforseoLighthousePayload( payload: unknown, input: { url: string; strategy: LighthouseStrategy }, ): 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); if (!parsed.success) { throw new Error( @@ -131,9 +91,8 @@ export function parseDataforseoLighthousePayload( } const fetchedAt = new Date().toISOString(); - const categories: Record = - result.categories ?? {}; - const audits: Record = result.audits ?? {}; + const categories = result.categories ?? {}; + const audits = result.audits ?? {}; const issueReport = buildStoredLighthouseIssues({ audits, categories }); const metrics = buildStoredLighthouseMetrics({ audits }); 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; } diff --git a/src/server/lib/lighthouseStoredPayload.ts b/src/server/lib/lighthouseStoredPayload.ts index 03376d5..9582396 100644 --- a/src/server/lib/lighthouseStoredPayload.ts +++ b/src/server/lib/lighthouseStoredPayload.ts @@ -11,7 +11,8 @@ export type RawLighthouseAudit = { details?: { overallSavingsMs?: number; overallSavingsBytes?: number; - items?: Array>; + /** Newer "insight" audits report a single object instead of a list. */ + items?: Array> | Record; }; }; @@ -83,10 +84,14 @@ export type StoredLighthousePayload = z.infer< typeof storedLighthousePayloadSchema >; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export function scoreToPercent( score: number | null | undefined, ): number | null { - if (score == null || Number.isNaN(score)) return null; + if (typeof score !== "number" || Number.isNaN(score)) return null; return Math.round(score * 100); } @@ -180,9 +185,10 @@ export function buildStoredLighthouseIssues(input: { const issues: StoredLighthouseIssue[] = []; 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) { - const auditKey = ref.id; + const auditKey = ref?.id; if (!auditKey) continue; const audit = input.audits[auditKey]; @@ -212,9 +218,13 @@ export function buildStoredLighthouseIssues(input: { typeof audit.details?.overallSavingsBytes === "number" ? audit.details.overallSavingsBytes : null; - const items = Array.isArray(audit.details?.items) - ? audit.details.items.slice(0, 10).map(compactItem) - : []; + const rawItems = audit.details?.items; + const itemList = Array.isArray(rawItems) + ? rawItems + : isRecord(rawItems) + ? [rawItems] + : []; + const items = itemList.filter(isRecord).slice(0, 10).map(compactItem); issues.push({ category,