Stop cloning the raw Lighthouse report through Zod (#528)
This commit is contained in:
parent
94b730124b
commit
8752269149
@ -196,43 +196,56 @@ describe("parseDataforseoLighthousePayload", () => {
|
||||
).toThrow("<root>");
|
||||
});
|
||||
|
||||
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: "unused-javascript" }],
|
||||
},
|
||||
},
|
||||
audits: {
|
||||
"unused-javascript": { title: 42, score: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ url: "https://everyapp.dev/", strategy: "mobile" },
|
||||
),
|
||||
).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" }],
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
scoreDisplayMode: "metricSavings",
|
||||
details: { items: { url: "https://everyapp.dev/" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -240,11 +253,14 @@ describe("parseDataforseoLighthousePayload", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
url: "https://everyapp.dev/",
|
||||
strategy: "mobile",
|
||||
},
|
||||
),
|
||||
).not.toThrow();
|
||||
{ url: "https://everyapp.dev/", strategy: "mobile" },
|
||||
);
|
||||
|
||||
expect(parsed.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
auditKey: "document-latency-insight",
|
||||
items: ['{"url":"https://everyapp.dev/"}'],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 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({
|
||||
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(), lighthouseCategorySchema)
|
||||
.optional()
|
||||
.default({}),
|
||||
audits: z.record(z.string(), lighthouseAuditSchema).optional().default({}),
|
||||
})
|
||||
.passthrough();
|
||||
.record(z.string(), z.custom<RawLighthouseCategory>())
|
||||
.optional(),
|
||||
audits: z.record(z.string(), z.custom<RawLighthouseAudit>()).optional(),
|
||||
});
|
||||
|
||||
const dataforseoTaskSchema = z
|
||||
.object({
|
||||
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({
|
||||
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
|
||||
@ -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<string, RawLighthouseCategory> =
|
||||
result.categories ?? {};
|
||||
const audits: Record<string, RawLighthouseAudit> = 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;
|
||||
}
|
||||
|
||||
@ -11,7 +11,8 @@ export type RawLighthouseAudit = {
|
||||
details?: {
|
||||
overallSavingsMs?: 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
|
||||
>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user