Your Name 68eab912d8
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
perf: optimize agent style rendering
2026-05-20 18:10:43 +08:00

199 lines
6.0 KiB
TypeScript

import { sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { clientPerformanceEvent } from "@/lib/db/schema";
export const runtime = "nodejs";
export const maxDuration = 10;
const SLOW_EVENT_MIN_DURATION_MS = 120;
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX_EVENTS = 20;
const TEXT_MAX_LENGTH = 256;
const allowedSections = [
"brand",
"style",
"mode",
"materials",
"generate",
"references",
"results",
"unknown",
] as const;
const eventSchema = z.object({
eventType: z.string().trim().min(1).max(64),
durationMs: z.coerce.number().finite().min(0).max(120_000),
blockingDurationMs: z.coerce.number().finite().min(0).max(120_000).optional(),
section: z.enum(allowedSections).optional(),
action: z.string().trim().max(64).optional(),
domNodeCount: z.coerce.number().int().min(0).max(1_000_000).optional(),
visibleImageCount: z.coerce.number().int().min(0).max(100_000).optional(),
loadedImageCount: z.coerce.number().int().min(0).max(100_000).optional(),
viewport: z
.object({
width: z.coerce.number().finite().min(0).max(20_000).optional(),
height: z.coerce.number().finite().min(0).max(20_000).optional(),
dpr: z.coerce.number().finite().min(0).max(8).optional(),
})
.optional(),
browser: z.string().trim().max(128).optional(),
path: z.string().trim().max(TEXT_MAX_LENGTH).optional(),
projectId: z.string().trim().max(64).optional().nullable(),
chatId: z.string().trim().uuid().optional().nullable(),
timestamp: z.union([z.string(), z.number(), z.date()]).optional(),
});
type RateWindow = {
windowStart: number;
count: number;
};
const rateWindows = new Map<string, RateWindow>();
function clampText(value: unknown, maxLength = TEXT_MAX_LENGTH) {
const normalized = String(value || "").trim();
if (!normalized) return null;
return normalized.slice(0, maxLength);
}
function stripPath(value: unknown) {
const normalized = clampText(value);
if (!normalized) return null;
if (/https?:\/\//i.test(normalized)) {
try {
return new URL(normalized).pathname.slice(0, TEXT_MAX_LENGTH) || "/";
} catch {
return "/";
}
}
return normalized.split("?")[0].split("#")[0].slice(0, TEXT_MAX_LENGTH) || "/";
}
function getRequesterIp(request: Request) {
const forwarded = request.headers.get("x-forwarded-for");
const firstForwarded = forwarded?.split(",")[0]?.trim();
return (
firstForwarded ||
request.headers.get("x-real-ip") ||
request.headers.get("cf-connecting-ip") ||
"unknown"
).slice(0, 64);
}
function isRateLimited(key: string) {
const now = Date.now();
const current = rateWindows.get(key);
if (!current || now - current.windowStart >= RATE_LIMIT_WINDOW_MS) {
rateWindows.set(key, { windowStart: now, count: 1 });
return false;
}
current.count += 1;
return current.count > RATE_LIMIT_MAX_EVENTS;
}
function normalizeOccurredAt(value: unknown) {
if (!value) return new Date();
const date =
value instanceof Date
? value
: typeof value === "number"
? new Date(value)
: new Date(String(value));
return Number.isNaN(date.getTime()) ? new Date() : date;
}
function includesDisallowedPayloadKeys(value: unknown) {
if (!value || typeof value !== "object") return false;
const keys = Object.keys(value as Record<string, unknown>).map((key) => key.toLowerCase());
return keys.some((key) =>
[
"prompt",
"image",
"imageurl",
"imageurls",
"thumbnailurl",
"originalurl",
"url",
"urls",
"input",
"text",
"content",
].includes(key)
);
}
async function maybePruneOldEvents() {
if (Math.random() > 0.01) return;
await db.execute(sql`
DELETE FROM "ClientPerformanceEvent"
WHERE "createdAt" < NOW() - INTERVAL '14 days'
`);
}
export async function POST(request: Request) {
try {
const contentType = request.headers.get("content-type") || "";
if (!contentType.toLowerCase().includes("application/json")) {
return NextResponse.json({ ok: true });
}
const raw = await request.json().catch(() => null);
if (!raw || typeof raw !== "object" || includesDisallowedPayloadKeys(raw)) {
return NextResponse.json({ ok: true });
}
const parsed = eventSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json({ ok: true });
}
const event = parsed.data;
if (event.durationMs < SLOW_EVENT_MIN_DURATION_MS) {
return NextResponse.json({ ok: true });
}
const session = await auth().catch(() => null);
const requesterIp = getRequesterIp(request);
const rateKey = `${session?.user?.id || "anon"}:${requesterIp}`;
if (isRateLimited(rateKey)) {
return NextResponse.json({ ok: true });
}
await maybePruneOldEvents().catch((error) => {
console.warn("[ClientPerformance] prune failed", error);
});
await db.insert(clientPerformanceEvent).values({
userId: session?.user?.id || null,
chatId: event.chatId || null,
projectId: clampText(event.projectId, 64),
eventType: clampText(event.eventType, 64) || "unknown",
section: event.section || "unknown",
action: clampText(event.action, 64),
path: stripPath(event.path),
durationMs: Math.round(event.durationMs),
blockingDurationMs: Math.round(event.blockingDurationMs || 0),
domNodeCount: event.domNodeCount || 0,
visibleImageCount: event.visibleImageCount || 0,
loadedImageCount: event.loadedImageCount || 0,
viewport: event.viewport || null,
browser: clampText(event.browser, 128),
metadata: {
source: "agent-style-client",
},
requesterIp,
requesterUserAgent: clampText(request.headers.get("user-agent"), 512),
occurredAt: normalizeOccurredAt(event.timestamp),
});
return NextResponse.json({ ok: true });
} catch (error) {
console.error("[ClientPerformance] failed to record agent style event:", error);
return NextResponse.json({ ok: true });
}
}