From a167d9aaef0bc21a7a981ffcea86500d3f308d6a Mon Sep 17 00:00:00 2001 From: Jonas Pacheco Date: Wed, 8 Apr 2026 15:38:16 -0300 Subject: [PATCH] perf: adiciona gzip compression e otimiza carregamento MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adiciona middleware compression para servir assets gzipados - Configura cache de 1 ano para assets estáticos (hash no filename) - Remove imports pesados do Agent.tsx (jspdf, docx, recharts) - Cria ChartRenderer com lazy loading para gráficos - Cria export-utils com dynamic imports para PDF/Word/CSV Reduz o chunk Agent de 893KB para 690KB (gzip 220KB). --- client/src/components/ChartRenderer.tsx | 146 ++++++++++++++++++++++++ client/src/lib/export-utils.ts | 142 +++++++++++++++++++++++ client/src/pages/Agent.tsx | 7 +- package-lock.json | 75 ++++++++++++ package.json | 2 + server/static.ts | 18 ++- 6 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 client/src/components/ChartRenderer.tsx create mode 100644 client/src/lib/export-utils.ts diff --git a/client/src/components/ChartRenderer.tsx b/client/src/components/ChartRenderer.tsx new file mode 100644 index 0000000..3d60822 --- /dev/null +++ b/client/src/components/ChartRenderer.tsx @@ -0,0 +1,146 @@ +import { lazy, Suspense } from "react"; + +interface ChartData { + type: "bar" | "line" | "pie" | "area"; + title: string; + data: Record[]; + xKey: string; + yKeys: string[]; + colors: string[]; +} + +// Lazy load do recharts para não pesar o bundle inicial +const RechartsComponents = lazy(() => + import("recharts").then((module) => ({ + default: function RechartsWrapper({ chart }: { chart: ChartData }) { + const { + BarChart, + Bar, + LineChart, + Line, + PieChart, + Pie, + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + Cell, + } = module; + + const renderChart = () => { + switch (chart.type) { + case "bar": + return ( + + + + + + + {chart.yKeys.map((key, index) => ( + + ))} + + ); + case "line": + return ( + + + + + + + {chart.yKeys.map((key, index) => ( + + ))} + + ); + case "area": + return ( + + + + + + + {chart.yKeys.map((key, index) => ( + + ))} + + ); + case "pie": + return ( + + + + + {chart.data.map((_, index) => ( + + ))} + + + ); + default: + return null; + } + }; + + return ( +
+ + {renderChart()} + +
+ ); + }, + })) +); + +interface ChartRendererProps { + chart: ChartData; +} + +export function ChartRenderer({ chart }: ChartRendererProps) { + return ( + +
+
+ } + > + +
+ ); +} diff --git a/client/src/lib/export-utils.ts b/client/src/lib/export-utils.ts new file mode 100644 index 0000000..78fa115 --- /dev/null +++ b/client/src/lib/export-utils.ts @@ -0,0 +1,142 @@ +// Utilitários de exportação com lazy loading dinâmico +// Reduz o bundle inicial do Agent.tsx + +export async function exportToPDF(content: string, prompt?: string, chartContainer?: HTMLElement | null) { + const [{ jsPDF }, html2canvas] = await Promise.all([ + import("jspdf"), + import("html2canvas").then(m => m.default) + ]); + + const doc = new jsPDF(); + const pageWidth = doc.internal.pageSize.getWidth(); + const margin = 20; + const maxWidth = pageWidth - margin * 2; + + doc.setFontSize(16); + doc.setTextColor(31, 51, 77); + doc.text("Arcádia Agent - Resultado", margin, 20); + + let currentY = 30; + + if (prompt) { + doc.setFontSize(10); + doc.setTextColor(90, 108, 125); + doc.text("Tarefa:", margin, currentY); + currentY += 7; + const promptLines = doc.splitTextToSize(prompt, maxWidth); + doc.text(promptLines, margin, currentY); + currentY += promptLines.length * 5 + 10; + } + + doc.setFontSize(12); + doc.setTextColor(31, 51, 77); + doc.text("Resposta:", margin, currentY); + currentY += 8; + + const textContent = content.replace(/__CHART_DATA__[\s\S]*?__END_CHART_DATA__/g, "").trim(); + doc.setFontSize(11); + const lines = doc.splitTextToSize(textContent, maxWidth); + doc.text(lines, margin, currentY); + currentY += lines.length * 5 + 10; + + // Verifica se há dados de gráfico e captura + const chartDataMatch = content.match(/__CHART_DATA__([\s\S]*?)__END_CHART_DATA__/); + if (chartDataMatch && chartContainer) { + try { + const canvas = await html2canvas(chartContainer, { + backgroundColor: "#ffffff", + scale: 2, + }); + const imgData = canvas.toDataURL("image/png"); + const imgWidth = maxWidth; + const imgHeight = (canvas.height * imgWidth) / canvas.width; + + if (currentY + imgHeight > doc.internal.pageSize.getHeight() - margin) { + doc.addPage(); + currentY = margin; + } + + doc.addImage(imgData, "PNG", margin, currentY, imgWidth, imgHeight); + } catch (e) { + console.error("Error capturing chart:", e); + } + } + + doc.save("arcadia-resultado.pdf"); +} + +export async function exportToWord(content: string, prompt?: string) { + const { Document, Packer, Paragraph, TextRun } = await import("docx"); + const { saveAs } = await import("file-saver"); + + const paragraphs = []; + + paragraphs.push( + new Paragraph({ + children: [new TextRun({ text: "Arcádia Agent - Resultado", bold: true, size: 32 })], + }) + ); + + if (prompt) { + paragraphs.push( + new Paragraph({ children: [] }), + new Paragraph({ + children: [new TextRun({ text: "Tarefa: ", bold: true }), new TextRun({ text: prompt })], + }) + ); + } + + paragraphs.push( + new Paragraph({ children: [] }), + new Paragraph({ + children: [new TextRun({ text: "Resposta:", bold: true })], + }) + ); + + content.split("\n").forEach((line) => { + paragraphs.push(new Paragraph({ children: [new TextRun({ text: line })] })); + }); + + const doc = new Document({ + sections: [{ properties: {}, children: paragraphs }], + }); + + const blob = await Packer.toBlob(doc); + saveAs(blob, "arcadia-resultado.docx"); +} + +export async function exportToText(content: string, prompt?: string) { + const { saveAs } = await import("file-saver"); + const textContent = prompt ? `Tarefa: ${prompt}\n\nResposta:\n${content}` : content; + const blob = new Blob([textContent], { type: "text/plain;charset=utf-8" }); + saveAs(blob, "arcadia-resultado.txt"); +} + +export async function exportToCSV(content: string) { + const { saveAs } = await import("file-saver"); + const lines = content.split("\n"); + let csvContent = ""; + + for (const line of lines) { + if (line.includes("|")) { + const cells = line + .split("|") + .map((cell) => cell.trim()) + .filter((cell) => cell && !cell.match(/^[-:]+$/)); + if (cells.length > 0) { + csvContent += cells.map((cell) => `"${cell.replace(/"/g, """)}"`).join(",") + "\n"; + } + } else if (line.includes("\t")) { + csvContent += + line + .split("\t") + .map((cell) => `"${cell.trim().replace(/"/g, """)}"`) + .join(",") + "\n"; + } else if (line.trim()) { + csvContent += `"${line.replace(/"/g, """)}"\n`; + } + } + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8" }); + saveAs(blob, "arcadia-resultado.csv"); +} diff --git a/client/src/pages/Agent.tsx b/client/src/pages/Agent.tsx index af1b6da..e490684 100644 --- a/client/src/pages/Agent.tsx +++ b/client/src/pages/Agent.tsx @@ -54,13 +54,10 @@ import { } from "lucide-react"; import { CardDescription } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { jsPDF } from "jspdf"; -import { Document, Packer, Paragraph, TextRun } from "docx"; -import { saveAs } from "file-saver"; -import html2canvas from "html2canvas"; -import { BarChart, Bar, LineChart, Line, PieChart, Pie, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell } from "recharts"; import { ResultViewer } from "@/components/ResultViewer"; import DevHistory from "@/components/DevHistory"; +import { ChartRenderer } from "@/components/ChartRenderer"; +import { exportToPDF, exportToWord, exportToText, exportToCSV } from "@/lib/export-utils"; interface ChartData { type: 'bar' | 'line' | 'pie' | 'area'; diff --git a/package-lock.json b/package-lock.json index 1ec6276..ec62c6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "@tiptap/starter-kit": "^3.15.3", "@types/adm-zip": "^0.5.7", "@types/bson": "^4.0.5", + "@types/compression": "^1.8.1", "@types/file-saver": "^2.0.7", "@types/imap": "^0.8.43", "@types/mailparser": "^3.4.6", @@ -66,6 +67,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "compression": "^1.8.1", "connect-pg-simple": "^10.0.0", "date-fns": "^3.6.0", "docx": "^9.5.1", @@ -7300,6 +7302,16 @@ "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -8506,6 +8518,69 @@ "node": ">= 0.8" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression/node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", diff --git a/package.json b/package.json index cf6ab76..d4db575 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@tiptap/starter-kit": "^3.15.3", "@types/adm-zip": "^0.5.7", "@types/bson": "^4.0.5", + "@types/compression": "^1.8.1", "@types/file-saver": "^2.0.7", "@types/imap": "^0.8.43", "@types/mailparser": "^3.4.6", @@ -73,6 +74,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "compression": "^1.8.1", "connect-pg-simple": "^10.0.0", "date-fns": "^3.6.0", "docx": "^9.5.1", diff --git a/server/static.ts b/server/static.ts index f5071cc..ae9e22e 100644 --- a/server/static.ts +++ b/server/static.ts @@ -1,6 +1,7 @@ import express, { type Express } from "express"; import fs from "fs"; import path from "path"; +import compression from "compression"; export function serveStatic(app: Express) { const distPath = path.resolve(__dirname, "public"); @@ -10,7 +11,22 @@ export function serveStatic(app: Express) { ); } - app.use(express.static(distPath)); + // Enable gzip compression for all responses + app.use(compression({ + level: 6, // balance between speed and compression + filter: (req, res) => { + // Compress all JSON and static assets + if (req.headers['x-no-compression']) return false; + return compression.filter(req, res); + }, + })); + + // Serve static files with proper caching + app.use(express.static(distPath, { + maxAge: '1y', // Cache static assets for 1 year (they have hash in filename) + etag: true, + lastModified: true, + })); // fall through to index.html if the file doesn't exist app.use("*", (_req, res) => {