chore(web): add bundle analysis gate

This commit is contained in:
Saravana Achu Mac 2026-05-05 12:45:20 -07:00
parent 2bf4b079ea
commit 6b896949d4
5 changed files with 144 additions and 316 deletions

View File

@ -11,6 +11,7 @@
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"build:analyze": "next build --webpack && node scripts/analyze-build.mjs",
"test:e2e": "playwright test"
},
"dependencies": {

View File

@ -0,0 +1,143 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { gzipSync } from "node:zlib";
const root = process.cwd();
const nextDir = path.join(root, ".next");
const appManifestPath = path.join(nextDir, "app-build-manifest.json");
const appPathsManifestPath = path.join(nextDir, "server", "app-paths-manifest.json");
const buildManifestPath = path.join(nextDir, "build-manifest.json");
const routeLimitKb = Number(process.env.NOTELETT_ROUTE_GZIP_LIMIT_KB ?? "650");
const sharedLimitKb = Number(process.env.NOTELETT_SHARED_GZIP_LIMIT_KB ?? "1500");
if (!existsSync(appManifestPath) && !existsSync(appPathsManifestPath)) {
throw new Error("Missing .next/app-build-manifest.json. Run `pnpm --filter @notelett/web run build` first.");
}
const manifest = existsSync(appManifestPath)
? JSON.parse(readFileSync(appManifestPath, "utf8"))
: appPathManifest();
const pages = manifest.pages ?? {};
const buildManifest = existsSync(buildManifestPath)
? JSON.parse(readFileSync(buildManifestPath, "utf8"))
: {};
const sharedLayoutFiles = findStaticChunks("app/layout")
.concat(findStaticChunks("app/(app)/layout"), findStaticChunks("app/(auth)/layout"));
const routeRows = Object.entries(pages)
.map(([route, files]) => routeSummary(route, Array.isArray(files) ? files : []))
.sort((a, b) => b.gzipKb - a.gzipKb);
const sharedFiles = new Set([...(buildManifest.rootMainFiles ?? []), ...sharedLayoutFiles]);
for (const row of routeRows) {
for (const file of row.files) {
if (file.includes("static/chunks/") && !file.includes("app/")) {
sharedFiles.add(file);
}
}
}
const sharedGzipKb = roundKb([...sharedFiles].reduce((total, file) => total + gzipBytes(file), 0));
const oversizedRoutes = routeRows.filter((row) => row.gzipKb > routeLimitKb);
const oversizedShared = sharedGzipKb > sharedLimitKb;
const summary = {
generatedAt: new Date().toISOString(),
limits: {
routeGzipKb: routeLimitKb,
sharedGzipKb: sharedLimitKb,
},
shared: {
gzipKb: sharedGzipKb,
files: [...sharedFiles].sort(),
},
routes: routeRows,
};
const outDir = path.join(nextDir, "analyze");
mkdirSync(outDir, { recursive: true });
writeFileSync(path.join(outDir, "notelett-bundle-summary.json"), `${JSON.stringify(summary, null, 2)}\n`);
console.log("NoteLett bundle summary (gzip KB)");
console.table(routeRows.slice(0, 20).map((row) => ({
route: row.route,
gzipKb: row.gzipKb,
jsKb: row.jsKb,
files: row.files.length,
})));
console.log(`Shared chunks: ${sharedGzipKb} KB gzip across ${sharedFiles.size} files`);
if (oversizedRoutes.length > 0 || oversizedShared) {
const routeText = oversizedRoutes.map((row) => `${row.route}: ${row.gzipKb} KB`).join(", ");
const sharedText = oversizedShared ? `shared chunks: ${sharedGzipKb} KB` : "";
throw new Error(`Bundle budget exceeded. ${[routeText, sharedText].filter(Boolean).join("; ")}`);
}
function routeSummary(route, files) {
const jsFiles = files.filter((file) => file.endsWith(".js"));
const jsBytes = jsFiles.reduce((total, file) => total + rawBytes(file), 0);
const gzBytes = jsFiles.reduce((total, file) => total + gzipBytes(file), 0);
return {
route,
files: jsFiles,
jsKb: roundKb(jsBytes),
gzipKb: roundKb(gzBytes),
};
}
function appPathManifest() {
const appPaths = JSON.parse(readFileSync(appPathsManifestPath, "utf8"));
const pages = {};
for (const [appRoute, serverFile] of Object.entries(appPaths)) {
const route = normalizeAppRoute(appRoute);
pages[route] = [
`server/${serverFile}`,
...findStaticChunks(String(serverFile).replace(/\.js$/, "")),
];
}
return { pages };
}
function normalizeAppRoute(appRoute) {
const route = appRoute
.replace(/\/page$/, "")
.replace(/^\/\((app|auth)\)/, "")
.replace(/^$/, "/");
return route === "" ? "/" : route;
}
function findStaticChunks(prefix) {
const chunkRoot = path.join(nextDir, "static", "chunks");
const relativePrefix = `static/chunks/${prefix}`;
return walk(chunkRoot)
.map((file) => path.relative(nextDir, file).replaceAll(path.sep, "/"))
.filter((file) => file.startsWith(relativePrefix) && file.endsWith(".js"));
}
function walk(dir) {
if (!existsSync(dir)) return [];
const entries = [];
for (const name of readdirSync(dir)) {
const full = path.join(dir, name);
if (statSync(full).isDirectory()) {
entries.push(...walk(full));
} else {
entries.push(full);
}
}
return entries;
}
function rawBytes(file) {
const fullPath = path.join(nextDir, file);
if (!existsSync(fullPath)) return 0;
return statSync(fullPath).size;
}
function gzipBytes(file) {
const fullPath = path.join(nextDir, file);
if (!existsSync(fullPath)) return 0;
return gzipSync(readFileSync(fullPath)).length;
}
function roundKb(bytes) {
return Math.round((bytes / 1024) * 10) / 10;
}

View File

@ -1,108 +0,0 @@
"use client";
import { useState } from "react";
import { Copy, FilePlus, Save, X, CheckCircle } from "lucide-react";
import { Button, Card } from "@/components/ui/Primitives";
import { toast } from "@/lib/toast";
import type { RunPromptOutput } from "@/lib/types";
interface PromptResultViewProps {
result: RunPromptOutput;
onDismiss: () => void;
onSaveAsNote?: (content: string) => void;
onApplyToNote?: (content: string) => void;
}
export function PromptResultView({
result,
onDismiss,
onSaveAsNote,
onApplyToNote,
}: PromptResultViewProps) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
await navigator.clipboard.writeText(result.content);
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
}
return (
<Card style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<strong style={{ fontSize: "var(--nl-fs-md)" }}>Result</strong>
<Button
variant="secondary"
size="sm"
onClick={onDismiss}
aria-label="Dismiss result"
style={{ padding: 4 }}
>
<X size={16} />
</Button>
</div>
{/* Content */}
<div
style={{
whiteSpace: "pre-wrap",
fontSize: "var(--nl-fs-sm)",
maxHeight: 400,
overflowY: "auto",
padding: "var(--nl-space-3)",
borderRadius: "var(--nl-radius-md)",
backgroundColor: "var(--nl-bg-secondary)",
lineHeight: 1.6,
}}
>
{result.content}
</div>
{/* Action buttons */}
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
{onSaveAsNote && (
<Button
onClick={() => onSaveAsNote(result.content)}
aria-label="Save as new note"
style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-1)" }}
>
<FilePlus size={14} /> Save as Note
</Button>
)}
{onApplyToNote && (
<Button
variant="secondary"
onClick={() => onApplyToNote(result.content)}
aria-label="Apply to current note"
style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-1)" }}
>
<Save size={14} /> Apply to Note
</Button>
)}
<Button
variant="secondary"
onClick={() => void handleCopy()}
aria-label="Copy result to clipboard"
style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-1)" }}
>
{copied ? <CheckCircle size={14} /> : <Copy size={14} />}
{copied ? "Copied" : "Copy"}
</Button>
<Button variant="secondary" onClick={onDismiss} aria-label="Discard result">
Discard
</Button>
</div>
{/* Metadata footer */}
{(result.model || result.usage) && (
<div style={{ fontSize: "var(--nl-fs-xs)", color: "var(--nl-text-secondary)", display: "flex", gap: "var(--nl-space-3)" }}>
{result.model && <span>Model: {result.model}</span>}
{result.usage && <span>{result.usage.totalTokens} tokens</span>}
{result.approvalState && <span>Status: {result.approvalState}</span>}
</div>
)}
</Card>
);
}

View File

@ -1,65 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
vi.mock("@/lib/prompt-client", () => ({
runPrompt: vi.fn().mockResolvedValue({ content: "Result", templateSlug: "summarize", outputType: "new_note" }),
}));
vi.mock("@/lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import { RunPromptModal } from "./RunPromptModal";
import type { PromptTemplate } from "@/lib/types";
const baseTemplate: PromptTemplate = {
id: "t1",
slug: "summarize",
name: "Summarize",
description: "Summarize the note",
category: "transform",
inputType: "text",
outputType: "new_note",
isBuiltin: true,
};
describe("RunPromptModal", () => {
const onClose = vi.fn();
const onResult = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("renders dialog with template name", () => {
render(<RunPromptModal template={baseTemplate} noteId="n1" workspaceId="ws1" onClose={onClose} onResult={onResult} />);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Summarize")).toBeInTheDocument();
});
it("renders close button with aria-label", () => {
render(<RunPromptModal template={baseTemplate} noteId="n1" workspaceId="ws1" onClose={onClose} onResult={onResult} />);
expect(screen.getByLabelText("Close modal")).toBeInTheDocument();
});
it("renders run button with aria-label", () => {
render(<RunPromptModal template={baseTemplate} noteId="n1" workspaceId="ws1" onClose={onClose} onResult={onResult} />);
expect(screen.getByLabelText("Run prompt")).toBeInTheDocument();
});
it("renders custom instructions textarea", () => {
render(<RunPromptModal template={baseTemplate} noteId="n1" workspaceId="ws1" onClose={onClose} onResult={onResult} />);
expect(screen.getByLabelText("Custom instructions")).toBeInTheDocument();
});
it("shows additional note IDs input for multi-note templates", () => {
const multiTemplate = { ...baseTemplate, inputType: "multi-note" as const };
render(<RunPromptModal template={multiTemplate} noteId="n1" workspaceId="ws1" onClose={onClose} onResult={onResult} />);
expect(screen.getByLabelText("Additional note IDs")).toBeInTheDocument();
});
it("does not show additional note IDs input for text templates", () => {
render(<RunPromptModal template={baseTemplate} noteId="n1" workspaceId="ws1" onClose={onClose} onResult={onResult} />);
expect(screen.queryByLabelText("Additional note IDs")).not.toBeInTheDocument();
});
});

View File

@ -1,143 +0,0 @@
"use client";
import { useState } from "react";
import { X, Play } from "lucide-react";
import { Badge, Button, Card } from "@/components/ui/Primitives";
import { runPrompt } from "@/lib/prompt-client";
import { toast } from "@/lib/toast";
import type { PromptTemplate, RunPromptOutput } from "@/lib/types";
interface RunPromptModalProps {
template: PromptTemplate;
noteId: string;
workspaceId: string;
onClose: () => void;
onResult: (result: RunPromptOutput) => void;
}
export function RunPromptModal({
template,
noteId,
workspaceId,
onClose,
onResult,
}: RunPromptModalProps) {
const [inlinePrompt, setInlinePrompt] = useState("");
const [additionalNoteIds, setAdditionalNoteIds] = useState("");
const [dryRun, setDryRun] = useState(false);
const [running, setRunning] = useState(false);
const isMultiNote = template.inputType === "multi-note";
async function handleRun() {
setRunning(true);
try {
const input: Parameters<typeof runPrompt>[0] = {
templateId: template.slug,
noteId,
workspaceId,
};
if (inlinePrompt.trim()) {
input.inputText = inlinePrompt.trim();
}
const result = await runPrompt(input);
onResult(result);
toast.success(`"${template.name}" completed`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Prompt execution failed");
} finally {
setRunning(false);
}
}
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 100,
display: "grid",
placeItems: "center",
backgroundColor: "var(--nl-overlay-scrim)",
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
aria-label={`Run: ${template.name}`}
>
<Card
padding="lg"
style={{
width: "min(90vw, 520px)",
display: "grid",
gap: "var(--nl-space-4)",
}}
>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<strong>{template.name}</strong>
<Button variant="secondary" size="sm" onClick={onClose} aria-label="Close modal" style={{ padding: 4 }}>
<X size={16} />
</Button>
</div>
<p style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }}>
{template.description}
</p>
{/* Inline prompt override */}
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}>
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Custom instructions (optional)</span>
<textarea
className="input"
rows={3}
value={inlinePrompt}
onChange={(e) => setInlinePrompt(e.target.value)}
placeholder="Add specific instructions to override or extend the template..."
aria-label="Custom instructions"
/>
</label>
{/* Multi-note selector */}
{isMultiNote && (
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}>
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Additional note IDs (comma-separated)</span>
<input
className="input"
type="text"
value={additionalNoteIds}
onChange={(e) => setAdditionalNoteIds(e.target.value)}
placeholder="note-id-1, note-id-2"
aria-label="Additional note IDs"
/>
</label>
)}
{/* Dry run toggle */}
<label style={{ display: "flex", gap: "var(--nl-space-2)", alignItems: "center", fontSize: "var(--nl-fs-sm)" }}>
<input type="checkbox" checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} />
Dry run (preview without saving)
</label>
{/* Info badges */}
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
<Badge>{template.inputType}</Badge>
<Badge>{template.outputType}</Badge>
<Badge>{template.category}</Badge>
</div>
{/* Run button */}
<Button
disabled={running}
loading={running}
onClick={() => void handleRun()}
aria-label={running ? "Running prompt..." : "Run prompt"}
style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "var(--nl-space-2)" }}
>
{!running ? <Play size={16} /> : null}
Run
</Button>
</Card>
</div>
);
}