Stop cloning the raw Lighthouse report through Zod (#528)
This commit is contained in:
parent
94b730124b
commit
8752269149
@ -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/"}'],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user