From 7a5c4399b5ca8cc8ccf1a8f612eeb55cf9d91061 Mon Sep 17 00:00:00 2001 From: Arno V Date: Sat, 31 Jan 2026 14:34:14 -0500 Subject: [PATCH 1/3] feat(bundlecheck): Add SQLite cache for results Introduce a persistent SQLite cache for bundlecheck using better-sqlite3 (packages and lockfile updated). Added packages/bundlecheck/src/cache.ts implementing init/get/set/clear/close, key normalization, FIFO eviction (max 100 entries) and helper functions. Integrate cache into CLI and trend flows: compute normalized cache keys (resolving `latest`, using exported getExternals), read from cache unless --force, store results after bundling, and add a displayResult helper for formatted output. Add a --force flag to defaults and parse to bypass cache. Export getExternals from bundler. Add comprehensive tests (cache.test.ts) and adjust trend tests to mock cache to avoid interference. --- packages/bundlecheck/package.json | 2 + .../bundlecheck/src/__tests__/cache.test.ts | 574 ++++++++++++++++++ .../bundlecheck/src/__tests__/trend.test.ts | 7 + packages/bundlecheck/src/bundlecheck.ts | 141 ++++- packages/bundlecheck/src/bundler.ts | 2 +- packages/bundlecheck/src/cache.ts | 254 ++++++++ packages/bundlecheck/src/defaults.ts | 1 + packages/bundlecheck/src/parse.ts | 11 + packages/bundlecheck/src/trend.ts | 53 +- pnpm-lock.yaml | 148 +++++ 10 files changed, 1158 insertions(+), 35 deletions(-) create mode 100644 packages/bundlecheck/src/__tests__/cache.test.ts create mode 100644 packages/bundlecheck/src/cache.ts diff --git a/packages/bundlecheck/package.json b/packages/bundlecheck/package.json index c566757b..b85b2138 100644 --- a/packages/bundlecheck/package.json +++ b/packages/bundlecheck/package.json @@ -36,6 +36,7 @@ "@inquirer/select": "5.0.4", "@node-cli/logger": "workspace:*", "@node-cli/parser": "workspace:*", + "better-sqlite3": "12.6.2", "esbuild": "0.27.2", "kleur": "4.1.5", "semver": "7.7.3" @@ -45,6 +46,7 @@ }, "devDependencies": { "@node-cli/comments": "workspace:*", + "@types/better-sqlite3": "7.6.13", "@vitest/coverage-v8": "4.0.18", "vitest": "4.0.18" } diff --git a/packages/bundlecheck/src/__tests__/cache.test.ts b/packages/bundlecheck/src/__tests__/cache.test.ts new file mode 100644 index 00000000..1ded4b27 --- /dev/null +++ b/packages/bundlecheck/src/__tests__/cache.test.ts @@ -0,0 +1,574 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BundleResult } from "../bundler.js"; +import { + clearCache, + closeCache, + getCacheCount, + getCachedResult, + normalizeCacheKey, + setCachedResult, +} from "../cache.js"; + +// Mock the cache directory to use a temp directory for tests +let testDir: string; +let testCounter = 0; + +beforeEach(() => { + // Close any existing database connection first (before restoring mocks) + closeCache(); + + // Restore mocks after closing db + vi.restoreAllMocks(); + + // Create a unique temp directory for each test (use counter for uniqueness) + testCounter++; + testDir = path.join( + os.tmpdir(), + `bundlecheck-test-${Date.now()}-${testCounter}-${Math.random().toString(36).substring(7)}`, + ); + fs.mkdirSync(testDir, { recursive: true }); + + // Mock os.homedir to return our test directory + vi.spyOn(os, "homedir").mockReturnValue(testDir); +}); + +afterEach(() => { + // Close the database connection first (before restoring mocks) + closeCache(); + + // Restore os.homedir + vi.restoreAllMocks(); + + // Clean up test directory + try { + fs.rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +describe("normalizeCacheKey", () => { + it("should normalize exports by sorting them", () => { + const key = normalizeCacheKey({ + packageName: "@mantine/core", + version: "8.0.0", + exports: ["Button", "ScrollArea", "Alert"], + platform: "browser", + gzipLevel: 5, + externals: ["react", "react-dom"], + noExternal: false, + }); + + expect(key.exports).toBe("Alert,Button,ScrollArea"); + }); + + it("should normalize externals by sorting them", () => { + const key = normalizeCacheKey({ + packageName: "lodash", + version: "4.17.21", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: ["react-dom", "react", "vue"], + noExternal: false, + }); + + expect(key.externals).toBe("react,react-dom,vue"); + }); + + it("should handle empty exports", () => { + const key = normalizeCacheKey({ + packageName: "lodash", + version: "4.17.21", + exports: undefined, + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + + expect(key.exports).toBe(""); + }); + + it("should handle empty externals", () => { + const key = normalizeCacheKey({ + packageName: "lodash", + version: "4.17.21", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + + expect(key.externals).toBe(""); + }); + + it("should convert noExternal boolean to number", () => { + const keyWithTrue = normalizeCacheKey({ + packageName: "lodash", + version: "4.17.21", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: true, + }); + + const keyWithFalse = normalizeCacheKey({ + packageName: "lodash", + version: "4.17.21", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + + expect(keyWithTrue.noExternal).toBe(1); + expect(keyWithFalse.noExternal).toBe(0); + }); + + it("should preserve all other fields", () => { + const key = normalizeCacheKey({ + packageName: "@mantine/core", + version: "8.0.0", + exports: ["Button"], + platform: "node", + gzipLevel: 9, + externals: ["react"], + noExternal: false, + }); + + expect(key.packageName).toBe("@mantine/core"); + expect(key.version).toBe("8.0.0"); + expect(key.platform).toBe("node"); + expect(key.gzipLevel).toBe(9); + }); + + it("should not mutate original arrays", () => { + const exports = ["Button", "Alert"]; + const externals = ["react-dom", "react"]; + + normalizeCacheKey({ + packageName: "test", + version: "1.0.0", + exports, + platform: "browser", + gzipLevel: 5, + externals, + noExternal: false, + }); + + // Original arrays should not be mutated + expect(exports).toEqual(["Button", "Alert"]); + expect(externals).toEqual(["react-dom", "react"]); + }); +}); + +describe("getCachedResult", () => { + it("should return null for missing cache entry", () => { + const key = normalizeCacheKey({ + packageName: "nonexistent", + version: "1.0.0", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + + const result = getCachedResult(key); + expect(result).toBeNull(); + }); +}); + +describe("setCachedResult and getCachedResult", () => { + const mockResult: BundleResult = { + packageName: "@mantine/core", + packageVersion: "8.0.0", + exports: ["Button", "ScrollArea"], + rawSize: 12345, + gzipSize: 4567, + gzipLevel: 5, + externals: ["react", "react-dom"], + dependencies: ["@mantine/hooks", "clsx"], + platform: "browser", + }; + + it("should store and retrieve a cache entry", () => { + const key = normalizeCacheKey({ + packageName: "@mantine/core", + version: "8.0.0", + exports: ["Button", "ScrollArea"], + platform: "browser", + gzipLevel: 5, + externals: ["react", "react-dom"], + noExternal: false, + }); + + setCachedResult(key, mockResult); + const cached = getCachedResult(key); + + expect(cached).not.toBeNull(); + expect(cached?.packageName).toBe("@mantine/core"); + expect(cached?.packageVersion).toBe("8.0.0"); + expect(cached?.rawSize).toBe(12345); + expect(cached?.gzipSize).toBe(4567); + expect(cached?.platform).toBe("browser"); + }); + + it("should handle null gzipSize for node platform", () => { + const nodeResult: BundleResult = { + ...mockResult, + gzipSize: null, + platform: "node", + }; + + const key = normalizeCacheKey({ + packageName: "@mantine/core", + version: "8.0.0", + exports: [], + platform: "node", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + + setCachedResult(key, nodeResult); + const cached = getCachedResult(key); + + expect(cached?.gzipSize).toBeNull(); + expect(cached?.platform).toBe("node"); + }); + + it("should return correct exports array from cached result", () => { + const key = normalizeCacheKey({ + packageName: "@mantine/core", + version: "8.0.0", + exports: ["Button", "ScrollArea"], + platform: "browser", + gzipLevel: 5, + externals: ["react", "react-dom"], + noExternal: false, + }); + + setCachedResult(key, mockResult); + const cached = getCachedResult(key); + + expect(cached?.exports).toEqual(["Button", "ScrollArea"]); + }); + + it("should return correct dependencies array from cached result", () => { + const key = normalizeCacheKey({ + packageName: "@mantine/core", + version: "8.0.0", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + + setCachedResult(key, mockResult); + const cached = getCachedResult(key); + + expect(cached?.dependencies).toEqual(["@mantine/hooks", "clsx"]); + }); + + it("should replace existing entry with same key", () => { + const key = normalizeCacheKey({ + packageName: "lodash", + version: "4.17.21", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + + const result1: BundleResult = { + ...mockResult, + packageName: "lodash", + rawSize: 1000, + }; + + const result2: BundleResult = { + ...mockResult, + packageName: "lodash", + rawSize: 2000, + }; + + setCachedResult(key, result1); + setCachedResult(key, result2); + + const cached = getCachedResult(key); + expect(cached?.rawSize).toBe(2000); + expect(getCacheCount()).toBe(1); + }); + + it("should differentiate by version", () => { + const key1 = normalizeCacheKey({ + packageName: "lodash", + version: "4.17.20", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + + const key2 = normalizeCacheKey({ + packageName: "lodash", + version: "4.17.21", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + + const result1: BundleResult = { ...mockResult, rawSize: 1000 }; + const result2: BundleResult = { ...mockResult, rawSize: 2000 }; + + setCachedResult(key1, result1); + setCachedResult(key2, result2); + + expect(getCachedResult(key1)?.rawSize).toBe(1000); + expect(getCachedResult(key2)?.rawSize).toBe(2000); + expect(getCacheCount()).toBe(2); + }); + + it("should differentiate by exports", () => { + const key1 = normalizeCacheKey({ + packageName: "@mantine/core", + version: "8.0.0", + exports: ["Button"], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + + const key2 = normalizeCacheKey({ + packageName: "@mantine/core", + version: "8.0.0", + exports: ["Button", "ScrollArea"], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + + const result1: BundleResult = { ...mockResult, rawSize: 1000 }; + const result2: BundleResult = { ...mockResult, rawSize: 2000 }; + + setCachedResult(key1, result1); + setCachedResult(key2, result2); + + expect(getCachedResult(key1)?.rawSize).toBe(1000); + expect(getCachedResult(key2)?.rawSize).toBe(2000); + }); + + it("should differentiate by platform", () => { + const keyBrowser = normalizeCacheKey({ + packageName: "lodash", + version: "4.17.21", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + + const keyNode = normalizeCacheKey({ + packageName: "lodash", + version: "4.17.21", + exports: [], + platform: "node", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + + const browserResult: BundleResult = { + ...mockResult, + platform: "browser", + rawSize: 1000, + }; + const nodeResult: BundleResult = { + ...mockResult, + platform: "node", + rawSize: 2000, + }; + + setCachedResult(keyBrowser, browserResult); + setCachedResult(keyNode, nodeResult); + + expect(getCachedResult(keyBrowser)?.rawSize).toBe(1000); + expect(getCachedResult(keyNode)?.rawSize).toBe(2000); + }); +}); + +describe("clearCache", () => { + it("should remove all cache entries", () => { + const mockResult: BundleResult = { + packageName: "test", + packageVersion: "1.0.0", + exports: [], + rawSize: 100, + gzipSize: 50, + gzipLevel: 5, + externals: [], + dependencies: [], + platform: "browser", + }; + + // Add some entries + for (let i = 0; i < 5; i++) { + const key = normalizeCacheKey({ + packageName: `package-${i}`, + version: "1.0.0", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + setCachedResult(key, { ...mockResult, packageName: `package-${i}` }); + } + + expect(getCacheCount()).toBe(5); + + clearCache(); + + expect(getCacheCount()).toBe(0); + }); +}); + +describe("cache eviction", () => { + it("should enforce maximum of 100 entries", () => { + const mockResult: BundleResult = { + packageName: "test", + packageVersion: "1.0.0", + exports: [], + rawSize: 100, + gzipSize: 50, + gzipLevel: 5, + externals: [], + dependencies: [], + platform: "browser", + }; + + // Add 105 entries + for (let i = 0; i < 105; i++) { + const key = normalizeCacheKey({ + packageName: `package-${i}`, + version: "1.0.0", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + setCachedResult(key, { ...mockResult, packageName: `package-${i}` }); + } + + // Should have at most 100 entries + expect(getCacheCount()).toBe(100); + }); + + it("should evict oldest entries first (FIFO)", () => { + const mockResult: BundleResult = { + packageName: "test", + packageVersion: "1.0.0", + exports: [], + rawSize: 100, + gzipSize: 50, + gzipLevel: 5, + externals: [], + dependencies: [], + platform: "browser", + }; + + // Add 105 entries + for (let i = 0; i < 105; i++) { + const key = normalizeCacheKey({ + packageName: `package-${i}`, + version: "1.0.0", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + setCachedResult(key, { ...mockResult, packageName: `package-${i}` }); + } + + // First 5 entries should be evicted + for (let i = 0; i < 5; i++) { + const key = normalizeCacheKey({ + packageName: `package-${i}`, + version: "1.0.0", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + expect(getCachedResult(key)).toBeNull(); + } + + // Entries 5-104 should still exist + for (let i = 5; i < 105; i++) { + const key = normalizeCacheKey({ + packageName: `package-${i}`, + version: "1.0.0", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + expect(getCachedResult(key)).not.toBeNull(); + } + }); +}); + +describe("getCacheCount", () => { + it("should return 0 for empty cache", () => { + expect(getCacheCount()).toBe(0); + }); + + it("should return correct count after adding entries", () => { + const mockResult: BundleResult = { + packageName: "test", + packageVersion: "1.0.0", + exports: [], + rawSize: 100, + gzipSize: 50, + gzipLevel: 5, + externals: [], + dependencies: [], + platform: "browser", + }; + + for (let i = 0; i < 3; i++) { + const key = normalizeCacheKey({ + packageName: `package-${i}`, + version: "1.0.0", + exports: [], + platform: "browser", + gzipLevel: 5, + externals: [], + noExternal: false, + }); + setCachedResult(key, mockResult); + } + + expect(getCacheCount()).toBe(3); + }); +}); diff --git a/packages/bundlecheck/src/__tests__/trend.test.ts b/packages/bundlecheck/src/__tests__/trend.test.ts index b4c32a3c..76d486ae 100644 --- a/packages/bundlecheck/src/__tests__/trend.test.ts +++ b/packages/bundlecheck/src/__tests__/trend.test.ts @@ -17,6 +17,13 @@ vi.mock("../bundler.js", async () => { }; }); +// Mock the cache module to avoid cache interference in tests +vi.mock("../cache.js", () => ({ + getCachedResult: vi.fn().mockReturnValue(null), + setCachedResult: vi.fn(), + normalizeCacheKey: vi.fn((options) => options), +})); + describe("selectTrendVersions", () => { it("should return first N versions (most recent)", () => { const versions = ["3.0.0", "2.0.0", "1.0.0", "0.9.0", "0.8.0", "0.7.0"]; diff --git a/packages/bundlecheck/src/bundlecheck.ts b/packages/bundlecheck/src/bundlecheck.ts index a6fd3830..d99d87cf 100644 --- a/packages/bundlecheck/src/bundlecheck.ts +++ b/packages/bundlecheck/src/bundlecheck.ts @@ -7,8 +7,14 @@ import kleur from "kleur"; import { checkBundleSize, formatBytes, + getExternals, parsePackageSpecifier, } from "./bundler.js"; +import { + getCachedResult, + normalizeCacheKey, + setCachedResult, +} from "./cache.js"; import { normalizePlatform, TREND_VERSION_COUNT } from "./defaults.js"; import { config } from "./parse.js"; import { @@ -28,6 +34,56 @@ const log = new Logger({ boring: flags?.boring, }); +/** + * Display bundle result in a formatted box + */ +function displayResult( + result: { + packageName: string; + packageVersion: string; + exports: string[]; + rawSize: number; + gzipSize: number | null; + gzipLevel: number; + externals: string[]; + dependencies: string[]; + platform: "browser" | "node"; + }, + isAutoDetected: boolean, +): void { + const blue = kleur.blue; + const green = kleur.green; + + const platformLabel = result.platform === "node" ? "node" : "browser"; + const platformNote = isAutoDetected ? " (auto-detected)" : ""; + + log.printBox( + [ + `${blue("Package:")} ${result.packageName} (${blue("version:")} ${result.packageVersion})`, + result.exports.length > 0 + ? `${blue("Exports:")} { ${result.exports.join(", ")} }` + : `${blue("Exports:")} * (entire package)`, + "", + `${blue("Raw size:")} ${formatBytes(result.rawSize)}`, + result.gzipSize !== null + ? `${blue("Gzip size:")} ${formatBytes(result.gzipSize)} (level ${result.gzipLevel})` + : `${blue("Gzip size:")} N/A (not applicable for node platform)`, + "", + result.externals.length > 0 + ? `${blue("Externals:")} ${result.externals.join(", ")}` + : `${blue("Externals:")} ${green("none")}`, + result.dependencies.length > 0 + ? `${blue("Dependencies:")} ${result.dependencies.join(", ")}` + : `${blue("Dependencies:")} ${green("none")}`, + `${blue("Platform:")} ${platformLabel}${platformNote}`, + ], + { + borderStyle: "round", + align: "left", + }, + ); +} + async function main() { let packageName = parameters?.["0"]; @@ -104,6 +160,7 @@ async function main() { boring: flags?.boring, registry: flags?.registry, platform, + force: flags?.force, }); if (results.length === 0) { @@ -164,9 +221,55 @@ async function main() { if (exports && exports.length > 0) { log.info(`Exports: { ${exports.join(", ")} }`); } - log.info("Please wait, installing and bundling...\n"); try { + // Parse package specifier to get name and version + const { name: baseName, version: requestedVersion } = + parsePackageSpecifier(packageName); + + // Resolve "latest" to actual version for cache key + let resolvedVersion = requestedVersion; + if (requestedVersion === "latest") { + const { tags } = await fetchPackageVersions({ + packageName: baseName, + registry: flags?.registry, + }); + resolvedVersion = tags.latest || requestedVersion; + } + + // Compute externals for cache key (same logic as bundler) + const externals = getExternals( + baseName, + additionalExternals, + flags?.noExternal, + ); + + // Determine platform for cache key (use input, undefined for auto) + const cachePlatform = platform || "browser"; + + // Build cache key + const cacheKey = normalizeCacheKey({ + packageName: baseName, + version: resolvedVersion, + exports, + platform: cachePlatform, + gzipLevel: flags?.gzipLevel ?? 5, + externals, + noExternal: flags?.noExternal ?? false, + }); + + // Check cache (unless --force flag is set) + if (!flags?.force) { + const cached = getCachedResult(cacheKey); + if (cached) { + log.info("NOTE: Using cached results\n"); + displayResult(cached, platform === undefined); + process.exit(0); + } + } + + log.info("Please wait, installing and bundling...\n"); + const result = await checkBundleSize({ packageName, exports, @@ -177,38 +280,10 @@ async function main() { platform, }); - const blue = kleur.blue; - const green = kleur.green; - - // Display results - const platformLabel = result.platform === "node" ? "node" : "browser"; - const platformNote = platform === undefined ? " (auto-detected)" : ""; - - log.printBox( - [ - `${blue("Package:")} ${result.packageName} (${blue("version:")} ${result.packageVersion})`, - result.exports.length > 0 - ? `${blue("Exports:")} { ${result.exports.join(", ")} }` - : `${blue("Exports:")} * (entire package)`, - "", - `${blue("Raw size:")} ${formatBytes(result.rawSize)}`, - result.gzipSize !== null - ? `${blue("Gzip size:")} ${formatBytes(result.gzipSize)} (level ${result.gzipLevel})` - : `${blue("Gzip size:")} N/A (not applicable for node platform)`, - "", - result.externals.length > 0 - ? `${blue("Externals:")} ${result.externals.join(", ")}` - : `${blue("Externals:")} ${green("none")}`, - result.dependencies.length > 0 - ? `${blue("Dependencies:")} ${result.dependencies.join(", ")}` - : `${blue("Dependencies:")} ${green("none")}`, - `${blue("Platform:")} ${platformLabel}${platformNote}`, - ], - { - borderStyle: "round", - align: "left", - }, - ); + // Store result in cache + setCachedResult(cacheKey, result); + + displayResult(result, platform === undefined); process.exit(0); } catch (error) { diff --git a/packages/bundlecheck/src/bundler.ts b/packages/bundlecheck/src/bundler.ts index 9ad866ba..5da79876 100644 --- a/packages/bundlecheck/src/bundler.ts +++ b/packages/bundlecheck/src/bundler.ts @@ -269,7 +269,7 @@ function generateEntryContent(options: EntryContentOptions): string { /** * Get externals list based on options */ -function getExternals( +export function getExternals( packageName: string, externals?: string[], noExternal?: boolean, diff --git a/packages/bundlecheck/src/cache.ts b/packages/bundlecheck/src/cache.ts new file mode 100644 index 00000000..55904f38 --- /dev/null +++ b/packages/bundlecheck/src/cache.ts @@ -0,0 +1,254 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import Database from "better-sqlite3"; +import type { BundleResult } from "./bundler.js"; + +const MAX_CACHE_ENTRIES = 100; + +/** + * Get the cache directory path (computed lazily to support testing) + */ +function getCacheDir(): string { + return path.join(os.homedir(), ".bundlecheck"); +} + +/** + * Get the cache database path (computed lazily to support testing) + */ +function getCacheDbPath(): string { + return path.join(getCacheDir(), "cache.db"); +} + +export type CacheKey = { + packageName: string; + version: string; + exports: string; + platform: "browser" | "node"; + gzipLevel: number; + externals: string; + noExternal: number; +}; + +export type CachedBundleResult = BundleResult; + +type CacheRow = { + id: number; + package_name: string; + version: string; + exports: string; + platform: string; + gzip_level: number; + externals: string; + no_external: number; + raw_size: number; + gzip_size: number | null; + dependencies: string; + display_name: string; + created_at: number; +}; + +let db: Database.Database | null = null; + +/** + * Initialize the cache database with proper pragmas and schema + */ +export function initCache(): Database.Database { + if (db) { + return db; + } + + // Ensure cache directory exists + const cacheDir = getCacheDir(); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + db = new Database(getCacheDbPath()); + + // Apply pragmas for better CLI performance + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + + // Create table if not exists + db.exec(` + CREATE TABLE IF NOT EXISTS bundle_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package_name TEXT NOT NULL, + version TEXT NOT NULL, + exports TEXT NOT NULL DEFAULT '', + platform TEXT NOT NULL, + gzip_level INTEGER NOT NULL, + externals TEXT NOT NULL DEFAULT '', + no_external INTEGER NOT NULL DEFAULT 0, + raw_size INTEGER NOT NULL, + gzip_size INTEGER, + dependencies TEXT NOT NULL DEFAULT '[]', + display_name TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(package_name, version, exports, platform, gzip_level, externals, no_external) + ); + + CREATE INDEX IF NOT EXISTS idx_created_at ON bundle_cache(created_at); + `); + + return db; +} + +/** + * Normalize cache key options to a consistent format + * Sorts exports and externals to ensure consistent cache hits + */ +export function normalizeCacheKey(options: { + packageName: string; + version: string; + exports?: string[]; + platform: "browser" | "node"; + gzipLevel: number; + externals: string[]; + noExternal: boolean; +}): CacheKey { + return { + packageName: options.packageName, + version: options.version, + exports: (options.exports || []).slice().sort().join(","), + platform: options.platform, + gzipLevel: options.gzipLevel, + externals: options.externals.slice().sort().join(","), + noExternal: options.noExternal ? 1 : 0, + }; +} + +/** + * Get a cached result if it exists + */ +export function getCachedResult(key: CacheKey): CachedBundleResult | null { + const database = initCache(); + + const stmt = database.prepare< + [string, string, string, string, number, string, number] + >(` + SELECT * FROM bundle_cache + WHERE package_name = ? + AND version = ? + AND exports = ? + AND platform = ? + AND gzip_level = ? + AND externals = ? + AND no_external = ? + `); + + const row = stmt.get( + key.packageName, + key.version, + key.exports, + key.platform, + key.gzipLevel, + key.externals, + key.noExternal, + ) as CacheRow | undefined; + + if (!row) { + return null; + } + + // Convert row to BundleResult + return { + packageName: row.display_name, + packageVersion: row.version, + exports: row.exports ? row.exports.split(",").filter(Boolean) : [], + rawSize: row.raw_size, + gzipSize: row.gzip_size, + gzipLevel: row.gzip_level, + externals: row.externals ? row.externals.split(",").filter(Boolean) : [], + dependencies: JSON.parse(row.dependencies) as string[], + platform: row.platform as "browser" | "node", + }; +} + +/** + * Store a result in the cache + */ +export function setCachedResult(key: CacheKey, result: BundleResult): void { + const database = initCache(); + + // Use INSERT OR REPLACE to handle duplicates + const stmt = database.prepare(` + INSERT OR REPLACE INTO bundle_cache ( + package_name, version, exports, platform, gzip_level, externals, no_external, + raw_size, gzip_size, dependencies, display_name, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + stmt.run( + key.packageName, + key.version, + key.exports, + key.platform, + key.gzipLevel, + key.externals, + key.noExternal, + result.rawSize, + result.gzipSize, + JSON.stringify(result.dependencies), + result.packageName, + Date.now(), + ); + + // Enforce max entries (FIFO eviction) + enforceMaxEntries(database); +} + +/** + * Enforce maximum cache entries by removing oldest entries + */ +function enforceMaxEntries(database: Database.Database): void { + const countResult = database + .prepare("SELECT COUNT(*) as count FROM bundle_cache") + .get() as { count: number }; + + if (countResult.count > MAX_CACHE_ENTRIES) { + const toDelete = countResult.count - MAX_CACHE_ENTRIES; + database + .prepare( + ` + DELETE FROM bundle_cache + WHERE id IN ( + SELECT id FROM bundle_cache + ORDER BY created_at ASC + LIMIT ? + ) + `, + ) + .run(toDelete); + } +} + +/** + * Clear all cache entries + */ +export function clearCache(): void { + const database = initCache(); + database.prepare("DELETE FROM bundle_cache").run(); +} + +/** + * Get the number of cached entries + */ +export function getCacheCount(): number { + const database = initCache(); + const result = database + .prepare("SELECT COUNT(*) as count FROM bundle_cache") + .get() as { count: number }; + return result.count; +} + +/** + * Close the database connection (useful for testing) + */ +export function closeCache(): void { + if (db) { + db.close(); + db = null; + } +} diff --git a/packages/bundlecheck/src/defaults.ts b/packages/bundlecheck/src/defaults.ts index 1f2d3f68..2bc3530c 100644 --- a/packages/bundlecheck/src/defaults.ts +++ b/packages/bundlecheck/src/defaults.ts @@ -8,6 +8,7 @@ export const defaultFlags = { versions: false, registry: "", platform: "auto", + force: false, }; /** diff --git a/packages/bundlecheck/src/parse.ts b/packages/bundlecheck/src/parse.ts index c7d22e8d..9b180bfd 100644 --- a/packages/bundlecheck/src/parse.ts +++ b/packages/bundlecheck/src/parse.ts @@ -17,6 +17,7 @@ export type Flags = { noExternal?: boolean; registry?: string; platform?: string; + force?: boolean; }; export type Parameters = { @@ -85,6 +86,10 @@ export const config: Configuration = parser({ comment: "## Check bundle size for Node.js platform (aliases: server, nodejs, backend)", }, + { + command: "bundlecheck lodash --force", + comment: "## Bypass cache and force re-fetch/re-calculation", + }, ], flags: { gzipLevel: { @@ -147,6 +152,12 @@ export const config: Configuration = parser({ 'Target platform: "auto" (default, detects from engines), "browser" or "node"', type: "string", }, + force: { + shortFlag: "f", + default: defaultFlags.force, + description: "Bypass cache and force re-fetch/re-calculation", + type: "boolean", + }, }, parameters: { package: { diff --git a/packages/bundlecheck/src/trend.ts b/packages/bundlecheck/src/trend.ts index d85387e1..b3b5b40f 100644 --- a/packages/bundlecheck/src/trend.ts +++ b/packages/bundlecheck/src/trend.ts @@ -1,7 +1,17 @@ import { Logger } from "@node-cli/logger"; import kleur from "kleur"; import { prerelease } from "semver"; -import { checkBundleSize, formatBytes } from "./bundler.js"; +import { + checkBundleSize, + formatBytes, + getExternals, + parsePackageSpecifier, +} from "./bundler.js"; +import { + getCachedResult, + normalizeCacheKey, + setCachedResult, +} from "./cache.js"; import { TREND_VERSION_COUNT } from "./defaults.js"; export type TrendResult = { @@ -22,6 +32,8 @@ export type TrendOptions = { registry?: string; /** Target platform. If undefined, auto-detects from package.json engines */ platform?: "browser" | "node"; + /** Bypass cache and force re-fetch/re-calculation */ + force?: boolean; }; /** @@ -54,13 +66,49 @@ export async function analyzeTrend( boring, registry, platform, + force, } = options; const log = new Logger({ boring }); const results: TrendResult[] = []; + // Parse base package name (without version) + const { name: baseName } = parsePackageSpecifier(packageName); + + // Compute externals for cache key (same logic as bundler) + const externals = getExternals(baseName, additionalExternals, noExternal); + + // Determine platform for cache key + const cachePlatform = platform || "browser"; + for (const version of versions) { const versionedPackage = `${packageName}@${version}`; + + // Build cache key for this version + const cacheKey = normalizeCacheKey({ + packageName: baseName, + version, + exports, + platform: cachePlatform, + gzipLevel: gzipLevel ?? 5, + externals, + noExternal: noExternal ?? false, + }); + + // Check cache first (unless --force flag is set) + if (!force) { + const cached = getCachedResult(cacheKey); + if (cached) { + log.info(` Checking ${version}... (cached)`); + results.push({ + version, + rawSize: cached.rawSize, + gzipSize: cached.gzipSize, + }); + continue; + } + } + log.info(` Checking ${version}...`); try { @@ -74,6 +122,9 @@ export async function analyzeTrend( platform, }); + // Store result in cache + setCachedResult(cacheKey, result); + results.push({ version, rawSize: result.rawSize, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b393965..d083ef22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@node-cli/parser': specifier: workspace:* version: link:../parser + better-sqlite3: + specifier: 12.6.2 + version: 12.6.2 esbuild: specifier: 0.27.2 version: 0.27.2 @@ -66,6 +69,9 @@ importers: '@node-cli/comments': specifier: workspace:* version: link:../comments + '@types/better-sqlite3': + specifier: 7.6.13 + version: 7.6.13 '@vitest/coverage-v8': specifier: 4.0.18 version: 4.0.18(vitest@4.0.18(@types/node@25.0.6)(happy-dom@20.1.0)(jsdom@27.4.0)(tsx@4.21.0)(yaml@2.8.1)) @@ -1810,6 +1816,9 @@ packages: '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -2182,6 +2191,10 @@ packages: before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + better-sqlite3@12.6.2: + resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -2201,6 +2214,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -2335,6 +2351,9 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} @@ -2636,6 +2655,10 @@ packages: babel-plugin-macros: optional: true + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + default-browser-id@5.0.0: resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==} engines: {node: '>=18'} @@ -2690,6 +2713,10 @@ packages: resolution: {integrity: sha512-rlpvsxUtM0PQvy9iZe640/IWwWYyBsTApREbA1pHOpmOUIl9MkP/U4z7vTtg4Oaojvqhxt7sdufnT0EzGaR31g==} engines: {node: '>=4'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -2852,6 +2879,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -2925,6 +2956,9 @@ packages: resolution: {integrity: sha512-VZR5I7k5wkD0HgFnMsq5hOsSc710MJMu5Nc5QYsbe38NN5iPV/XTObYLc/cpttRTf6lX538+5uO1ZQRhYibiZQ==} engines: {node: '>=18'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} @@ -3091,6 +3125,9 @@ packages: gitconfiglocal@1.0.0: resolution: {integrity: sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3939,6 +3976,9 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -3981,6 +4021,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -3991,6 +4034,10 @@ packages: nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + node-abi@3.87.0: + resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} + engines: {node: '>=10'} + node-gyp@11.5.0: resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -4427,6 +4474,11 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + prettier@3.7.4: resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} engines: {node: '>=14'} @@ -4513,6 +4565,10 @@ packages: resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} engines: {node: '>= 0.8'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -4810,6 +4866,12 @@ packages: resolution: {integrity: sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==} engines: {node: ^20.17.0 || >=22.9.0} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} @@ -4995,6 +5057,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -5023,6 +5089,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -5176,6 +5245,9 @@ packages: resolution: {integrity: sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==} engines: {node: ^20.17.0 || >=22.9.0} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-fest@0.18.1: resolution: {integrity: sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==} engines: {node: '>=10'} @@ -6812,6 +6884,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 25.0.6 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -7276,6 +7352,11 @@ snapshots: before-after-hook@2.2.3: {} + better-sqlite3@12.6.2: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -7301,6 +7382,10 @@ snapshots: binary-extensions@2.3.0: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -7474,6 +7559,8 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@1.1.4: {} + chownr@2.0.0: {} chownr@3.0.0: {} @@ -7749,6 +7836,8 @@ snapshots: dedent@1.5.3: {} + deep-extend@0.6.0: {} + default-browser-id@5.0.0: {} default-browser@5.4.0: @@ -7790,6 +7879,8 @@ snapshots: detect-indent@5.0.0: {} + detect-libc@2.1.2: {} + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 @@ -8059,6 +8150,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.1 + expand-template@2.0.3: {} + expect-type@1.3.0: {} exponential-backoff@3.1.2: {} @@ -8144,6 +8237,8 @@ snapshots: token-types: 6.0.0 uint8array-extras: 1.4.0 + file-uri-to-path@1.0.0: {} + filelist@1.0.4: dependencies: minimatch: 5.1.6 @@ -8323,6 +8418,8 @@ snapshots: dependencies: ini: 1.3.8 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -9301,6 +9398,8 @@ snapshots: dependencies: minipass: 7.1.2 + mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} mlly@1.7.4: @@ -9338,12 +9437,18 @@ snapshots: nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + negotiator@1.0.0: {} neo-async@2.6.2: {} nice-try@1.0.5: {} + node-abi@3.87.0: + dependencies: + semver: 7.7.3 + node-gyp@11.5.0: dependencies: env-paths: 2.2.1 @@ -9902,6 +10007,21 @@ snapshots: powershell-utils@0.1.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.87.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prettier@3.7.4: {} pretty-format@30.2.0: @@ -9968,6 +10088,13 @@ snapshots: random-bytes@1.0.0: {} + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-is@18.3.1: {} read-cmd-shim@4.0.0: {} @@ -10298,6 +10425,14 @@ snapshots: transitivePeerDependencies: - supports-color + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-update-notifier@2.0.0: dependencies: semver: 7.7.3 @@ -10494,6 +10629,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@2.0.1: {} + strip-json-comments@5.0.3: {} strtok3@9.1.1: @@ -10523,6 +10660,13 @@ snapshots: symbol-tree@3.2.4: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -10690,6 +10834,10 @@ snapshots: transitivePeerDependencies: - supports-color + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-fest@0.18.1: {} type-fest@0.4.1: {} From 80b29e195b416547f7abebd0c23810a81e0611d0 Mon Sep 17 00:00:00 2001 From: Arno V Date: Sat, 31 Jan 2026 14:37:05 -0500 Subject: [PATCH 2/3] update --- packages/bundlecheck/README.md | 45 ++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 14 ++++++----- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/bundlecheck/README.md b/packages/bundlecheck/README.md index 0c425b66..7690da3c 100644 --- a/packages/bundlecheck/README.md +++ b/packages/bundlecheck/README.md @@ -16,6 +16,7 @@ A CLI tool to check the bundle size of npm packages, similar to [bundlephobia.co - **Platform support**: target `browser` (default) or `node` with smart auto-detection - Custom npm registry support (for private registries) - Fast bundling using esbuild (with pnpm support) +- **Local caching** for faster repeated lookups (SQLite-based, max 100 entries) ## Installation @@ -105,6 +106,7 @@ bundlecheck lodash "debounce,throttle" | `--noExternal` | `-n` | Do not mark any packages as external | | `--registry ` | `-r ` | Custom npm registry URL (default: registry.npmjs.org) | | `--platform ` | `-p ` | Target platform: `auto` (default), `browser`, or `node` | +| `--force` | `-f` | Bypass cache and force re-fetch/re-calculation | ### Examples @@ -145,6 +147,9 @@ bundlecheck express --platform node # Auto-detect platform (default behavior) bundlecheck express # auto-detects "node" from package.json engines + +# Bypass cache and force re-fetch +bundlecheck lodash --force ``` ## How It Works @@ -198,6 +203,46 @@ bundlecheck my-local-pkg --registry http://localhost:4873 Note: If the registry requires authentication, ensure your npm/pnpm is configured with the appropriate credentials (via `.npmrc` or environment variables). +## Cache + +Bundle size results are cached locally to speed up repeated lookups. The cache is stored in `~/.bundlecheck/cache.db` using SQLite. + +### How it works + +- Results are cached based on: package name, version, exports, platform, gzip level, and externals configuration +- The cache holds up to **100 entries** (oldest entries are evicted first) +- When you check a package, the CLI first looks for a cached result with matching parameters + +### Smart version matching + +The cache uses **resolved versions**, not the requested specifier. This means: + +```bash +bundlecheck @mantine/core # Resolves "latest" to e.g. 8.0.0, caches as 8.0.0 +bundlecheck @mantine/core@8.0.0 # Cache hit! Same resolved version +``` + +This also works with `--trend`: if you previously checked `@mantine/core` (which resolved to v8.0.0), running `--trend` will use the cached result for v8.0.0 and only fetch the other versions. + +### Bypassing the cache + +Use the `--force` flag to skip the cache and re-fetch/re-calculate: + +```bash +bundlecheck lodash --force # Always fetches fresh data +bundlecheck lodash -f # Short form +``` + +### Cache location + +The cache database is stored at: + +``` +~/.bundlecheck/cache.db +``` + +To clear the cache, simply delete this file or the `~/.bundlecheck` directory. + ## License MIT - see [LICENSE](./LICENSE) for details. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index db709d12..32666867 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,16 +5,18 @@ packages: ignoredBuiltDependencies: - nx +minimumReleaseAge: 720 + +minimumReleaseAgeExclude: + - '@versini/dev-dependencies-common' + - '@versini/dev-dependencies-server' + - '@versini/dev-dependencies-types' + onlyBuiltDependencies: - '@swc/core' + - better-sqlite3 - esbuild shamefullyHoist: true strictDepBuilds: true -minimumReleaseAge: 720 # twelve hours in minutes -minimumReleaseAgeExclude: - - "@versini/dev-dependencies-common" - - "@versini/dev-dependencies-server" - - "@versini/dev-dependencies-types" - \ No newline at end of file From 17cec902cff31785309d463aa5735f5b1dd6f25b Mon Sep 17 00:00:00 2001 From: Arno V Date: Sat, 31 Jan 2026 15:29:42 -0500 Subject: [PATCH 3/3] code review --- packages/bundlecheck/README.md | 2 +- packages/bundlecheck/src/bundlecheck.ts | 6 +- packages/bundlecheck/src/cache.ts | 190 ++++++++++++++---------- packages/bundlecheck/src/trend.ts | 6 +- 4 files changed, 120 insertions(+), 84 deletions(-) diff --git a/packages/bundlecheck/README.md b/packages/bundlecheck/README.md index 7690da3c..5fc10b27 100644 --- a/packages/bundlecheck/README.md +++ b/packages/bundlecheck/README.md @@ -210,7 +210,7 @@ Bundle size results are cached locally to speed up repeated lookups. The cache i ### How it works - Results are cached based on: package name, version, exports, platform, gzip level, and externals configuration -- The cache holds up to **100 entries** (oldest entries are evicted first) +- The cache holds up to **100 entries** (least recently used entries are evicted first) - When you check a package, the CLI first looks for a cached result with matching parameters ### Smart version matching diff --git a/packages/bundlecheck/src/bundlecheck.ts b/packages/bundlecheck/src/bundlecheck.ts index d99d87cf..3e638a96 100644 --- a/packages/bundlecheck/src/bundlecheck.ts +++ b/packages/bundlecheck/src/bundlecheck.ts @@ -244,15 +244,13 @@ async function main() { flags?.noExternal, ); - // Determine platform for cache key (use input, undefined for auto) - const cachePlatform = platform || "browser"; - // Build cache key + // Note: platform can be undefined (auto-detect), which is stored as "auto" in cache const cacheKey = normalizeCacheKey({ packageName: baseName, version: resolvedVersion, exports, - platform: cachePlatform, + platform, gzipLevel: flags?.gzipLevel ?? 5, externals, noExternal: flags?.noExternal ?? false, diff --git a/packages/bundlecheck/src/cache.ts b/packages/bundlecheck/src/cache.ts index 55904f38..e0d7d46d 100644 --- a/packages/bundlecheck/src/cache.ts +++ b/packages/bundlecheck/src/cache.ts @@ -24,7 +24,8 @@ export type CacheKey = { packageName: string; version: string; exports: string; - platform: "browser" | "node"; + /** Platform: "browser", "node", or "auto" (for auto-detection) */ + platform: "browser" | "node" | "auto"; gzipLevel: number; externals: string; noExternal: number; @@ -98,12 +99,17 @@ export function initCache(): Database.Database { /** * Normalize cache key options to a consistent format * Sorts exports and externals to ensure consistent cache hits + * + * Note: platform can be "browser", "node", or "auto" (when user doesn't specify). + * Using "auto" in the cache key ensures that auto-detected results are cached + * separately from explicitly specified platform results. */ export function normalizeCacheKey(options: { packageName: string; version: string; exports?: string[]; - platform: "browser" | "node"; + /** Platform: "browser", "node", or undefined (treated as "auto") */ + platform: "browser" | "node" | undefined; gzipLevel: number; externals: string[]; noExternal: boolean; @@ -112,7 +118,8 @@ export function normalizeCacheKey(options: { packageName: options.packageName, version: options.version, exports: (options.exports || []).slice().sort().join(","), - platform: options.platform, + // Use "auto" when platform is undefined to distinguish from explicit platform + platform: options.platform ?? "auto", gzipLevel: options.gzipLevel, externals: options.externals.slice().sort().join(","), noExternal: options.noExternal ? 1 : 0, @@ -121,86 +128,109 @@ export function normalizeCacheKey(options: { /** * Get a cached result if it exists + * Returns null if not found or if an error occurs (graceful degradation) */ export function getCachedResult(key: CacheKey): CachedBundleResult | null { - const database = initCache(); - - const stmt = database.prepare< - [string, string, string, string, number, string, number] - >(` - SELECT * FROM bundle_cache - WHERE package_name = ? - AND version = ? - AND exports = ? - AND platform = ? - AND gzip_level = ? - AND externals = ? - AND no_external = ? - `); + try { + const database = initCache(); + + const stmt = database.prepare< + [string, string, string, string, number, string, number] + >(` + SELECT * FROM bundle_cache + WHERE package_name = ? + AND version = ? + AND exports = ? + AND platform = ? + AND gzip_level = ? + AND externals = ? + AND no_external = ? + `); + + const row = stmt.get( + key.packageName, + key.version, + key.exports, + key.platform, + key.gzipLevel, + key.externals, + key.noExternal, + ) as CacheRow | undefined; + + if (!row) { + return null; + } + + // Parse dependencies with error handling for corrupted data + let dependencies: string[] = []; + try { + dependencies = JSON.parse(row.dependencies) as string[]; + } catch { + // If JSON is corrupted, use empty array + dependencies = []; + } - const row = stmt.get( - key.packageName, - key.version, - key.exports, - key.platform, - key.gzipLevel, - key.externals, - key.noExternal, - ) as CacheRow | undefined; - - if (!row) { + // Convert row to BundleResult + return { + packageName: row.display_name, + packageVersion: row.version, + exports: row.exports ? row.exports.split(",").filter(Boolean) : [], + rawSize: row.raw_size, + gzipSize: row.gzip_size, + gzipLevel: row.gzip_level, + externals: row.externals ? row.externals.split(",").filter(Boolean) : [], + dependencies, + platform: row.platform as "browser" | "node", + }; + } catch { + // On any database error, return null (graceful degradation - continue without cache) return null; } - - // Convert row to BundleResult - return { - packageName: row.display_name, - packageVersion: row.version, - exports: row.exports ? row.exports.split(",").filter(Boolean) : [], - rawSize: row.raw_size, - gzipSize: row.gzip_size, - gzipLevel: row.gzip_level, - externals: row.externals ? row.externals.split(",").filter(Boolean) : [], - dependencies: JSON.parse(row.dependencies) as string[], - platform: row.platform as "browser" | "node", - }; } /** * Store a result in the cache + * Silently fails on errors (graceful degradation - CLI continues without caching) */ export function setCachedResult(key: CacheKey, result: BundleResult): void { - const database = initCache(); - - // Use INSERT OR REPLACE to handle duplicates - const stmt = database.prepare(` - INSERT OR REPLACE INTO bundle_cache ( - package_name, version, exports, platform, gzip_level, externals, no_external, - raw_size, gzip_size, dependencies, display_name, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); + try { + const database = initCache(); - stmt.run( - key.packageName, - key.version, - key.exports, - key.platform, - key.gzipLevel, - key.externals, - key.noExternal, - result.rawSize, - result.gzipSize, - JSON.stringify(result.dependencies), - result.packageName, - Date.now(), - ); - - // Enforce max entries (FIFO eviction) - enforceMaxEntries(database); + // Use INSERT OR REPLACE to handle duplicates + // Note: This updates created_at on replace, making this LRU-style eviction + const stmt = database.prepare(` + INSERT OR REPLACE INTO bundle_cache ( + package_name, version, exports, platform, gzip_level, externals, no_external, + raw_size, gzip_size, dependencies, display_name, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + stmt.run( + key.packageName, + key.version, + key.exports, + key.platform, + key.gzipLevel, + key.externals, + key.noExternal, + result.rawSize, + result.gzipSize, + JSON.stringify(result.dependencies), + result.packageName, + Date.now(), + ); + + // Enforce max entries (LRU-style eviction based on created_at) + enforceMaxEntries(database); + } catch { + // On any database error, silently continue (graceful degradation) + } } /** - * Enforce maximum cache entries by removing oldest entries + * Enforce maximum cache entries by removing entries with oldest timestamps + * Uses LRU-style eviction: entries that are re-checked get updated timestamps + * and move to the end of the eviction queue */ function enforceMaxEntries(database: Database.Database): void { const countResult = database @@ -226,21 +256,31 @@ function enforceMaxEntries(database: Database.Database): void { /** * Clear all cache entries + * Silently fails on errors */ export function clearCache(): void { - const database = initCache(); - database.prepare("DELETE FROM bundle_cache").run(); + try { + const database = initCache(); + database.prepare("DELETE FROM bundle_cache").run(); + } catch { + // Silently ignore errors + } } /** * Get the number of cached entries + * Returns 0 on error */ export function getCacheCount(): number { - const database = initCache(); - const result = database - .prepare("SELECT COUNT(*) as count FROM bundle_cache") - .get() as { count: number }; - return result.count; + try { + const database = initCache(); + const result = database + .prepare("SELECT COUNT(*) as count FROM bundle_cache") + .get() as { count: number }; + return result.count; + } catch { + return 0; + } } /** diff --git a/packages/bundlecheck/src/trend.ts b/packages/bundlecheck/src/trend.ts index b3b5b40f..45b84450 100644 --- a/packages/bundlecheck/src/trend.ts +++ b/packages/bundlecheck/src/trend.ts @@ -78,18 +78,16 @@ export async function analyzeTrend( // Compute externals for cache key (same logic as bundler) const externals = getExternals(baseName, additionalExternals, noExternal); - // Determine platform for cache key - const cachePlatform = platform || "browser"; - for (const version of versions) { const versionedPackage = `${packageName}@${version}`; // Build cache key for this version + // Note: platform can be undefined (auto-detect), which is stored as "auto" in cache const cacheKey = normalizeCacheKey({ packageName: baseName, version, exports, - platform: cachePlatform, + platform, gzipLevel: gzipLevel ?? 5, externals, noExternal: noExternal ?? false,