diff --git a/packages/benchmark/__tests__/runner-edge-cases.test.ts b/packages/benchmark/__tests__/runner-edge-cases.test.ts index d6f1747c4..eabfb36fe 100644 --- a/packages/benchmark/__tests__/runner-edge-cases.test.ts +++ b/packages/benchmark/__tests__/runner-edge-cases.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, test, vi } from "vitest"; import { benchmark } from "../src/index.ts"; +import { calculateCompressorMetrics, runIterations } from "../src/runner.ts"; import type { BenchmarkOptions } from "../src/types.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -276,4 +277,30 @@ describe("Runner Edge Cases", () => { const result = results.files[0]?.results[0]; expect(result?.success).toBe(true); }); + + describe("runIterations validation", () => { + const mockCompressor = vi.fn(); + + test("should throw when iterationCount is 0", async () => { + await expect( + runIterations(fixtureJS, mockCompressor, "/tmp/out.js", 0, {}) + ).rejects.toThrow("iterationCount must be at least 1, got 0"); + }); + + test("should throw when iterationCount is negative", async () => { + await expect( + runIterations(fixtureJS, mockCompressor, "/tmp/out.js", -1, {}) + ).rejects.toThrow("iterationCount must be at least 1, got -1"); + }); + }); + + describe("calculateCompressorMetrics validation", () => { + test("should throw when times array is empty", async () => { + await expect( + calculateCompressorMetrics("test", [], fixtureJS, 1000, {}) + ).rejects.toThrow( + "Cannot calculate metrics for 'test': no timing data provided" + ); + }); + }); }); diff --git a/packages/benchmark/src/runner.ts b/packages/benchmark/src/runner.ts index 31dce2d6b..da16068b3 100644 --- a/packages/benchmark/src/runner.ts +++ b/packages/benchmark/src/runner.ts @@ -6,8 +6,10 @@ import { statSync, unlinkSync } from "node:fs"; import { minify } from "@node-minify/core"; +import type { Compressor } from "@node-minify/types"; import { getFilesizeBrotliInBytes, + getFilesizeBrotliRaw, getFilesizeGzippedInBytes, getFilesizeGzippedRaw, prettyBytes, @@ -22,6 +24,176 @@ import type { FileResult, } from "./types.ts"; +/** + * Run warmup iterations for a compressor to stabilize JIT and caches. + * + * @param file - Input file path + * @param compressor - The compressor function + * @param warmupFile - Path for warmup output file + * @param warmupCount - Number of warmup iterations + * @param options - Benchmark options containing type and compressor options + */ +export async function runWarmup( + file: string, + compressor: Compressor, + warmupFile: string, + warmupCount: number, + options: Pick +): Promise { + for (let i = 0; i < warmupCount; i++) { + await minify({ + compressor, + input: file, + output: warmupFile, + ...(options.type && { type: options.type as "js" | "css" }), + options: options.compressorOptions, + }); + } +} + +/** + * Result of running timed iterations. + */ +export type IterationsResult = { + times: number[]; + outputFile: string; +}; + +/** + * Run timed iterations of a compressor and return timing data. + * + * @param file - Input file path + * @param compressor - The compressor function + * @param outputFileBase - Base path for output files (will be used as final output) + * @param iterationCount - Number of iterations to run + * @param options - Benchmark options containing type and compressor options + * @returns The array of iteration times and the final output file path + */ +export async function runIterations( + file: string, + compressor: Compressor, + outputFileBase: string, + iterationCount: number, + options: Pick +): Promise { + if (iterationCount < 1) { + throw new Error( + `iterationCount must be at least 1, got ${iterationCount}` + ); + } + + const times: number[] = []; + + for (let i = 0; i < iterationCount; i++) { + const start = performance.now(); + await minify({ + compressor, + input: file, + output: outputFileBase, + ...(options.type && { type: options.type as "js" | "css" }), + options: options.compressorOptions, + }); + times.push(performance.now() - start); + } + + return { times, outputFile: outputFileBase }; +} + +/** + * Calculate final metrics from iteration results. + * + * @param name - Compressor name + * @param times - Array of iteration times in ms + * @param outputFile - Path to the output file (for size measurement) + * @param originalSizeBytes - Original file size in bytes + * @param options - Benchmark options for optional gzip/brotli/verbose + * @returns Populated CompressorMetrics object + */ +export async function calculateCompressorMetrics( + name: string, + times: number[], + outputFile: string, + originalSizeBytes: number, + options: Pick +): Promise { + if (times.length === 0) { + throw new Error( + `Cannot calculate metrics for '${name}': no timing data provided` + ); + } + + const avgTime = times.reduce((a, b) => a + b, 0) / times.length; + const outStats = statSync(outputFile); + const sizeBytes = outStats.size; + + const metrics: CompressorMetrics = { + compressor: name, + sizeBytes, + size: prettyBytes(sizeBytes), + timeMs: avgTime, + timeMinMs: Math.min(...times), + timeMaxMs: Math.max(...times), + iterationTimes: options.verbose ? times : undefined, + reductionPercent: calculateReduction(originalSizeBytes, sizeBytes), + success: true, + }; + + if (options.includeGzip) { + metrics.gzipSize = await getFilesizeGzippedInBytes(outputFile); + metrics.gzipBytes = await getFilesizeGzippedRaw(outputFile); + } + + if (options.includeBrotli) { + metrics.brotliSize = await getFilesizeBrotliInBytes(outputFile); + metrics.brotliBytes = await getFilesizeBrotliRaw(outputFile); + } + + return metrics; +} + +/** + * Create an error metrics object for failed compressor runs. + * + * @param name - Compressor name + * @param error - The error message or Error object + * @returns CompressorMetrics indicating failure + */ +export function createErrorMetrics( + name: string, + error: string | Error +): CompressorMetrics { + return { + compressor: name, + sizeBytes: 0, + size: "0 B", + timeMs: 0, + reductionPercent: 0, + success: false, + error: error instanceof Error ? error.message : error, + }; +} + +/** + * Clean up temporary files created during benchmarking. + * + * @param files - Array of file paths to delete + */ +export function cleanupTempFiles(files: string[]): void { + for (const file of files) { + try { + unlinkSync(file); + } catch { + // Ignore cleanup errors + } + } +} + +/** + * Run a complete benchmark across all input files and compressors. + * + * @param options - Benchmark configuration + * @returns Complete benchmark results with file results and summary + */ export async function runBenchmark( options: BenchmarkOptions ): Promise { @@ -55,11 +227,11 @@ export async function runBenchmark( } /** - * Benchmarks a single input file using the configured compressors and returns per-compressor metrics. + * Benchmarks a single input file using the configured compressors. * * @param file - Path to the input file to benchmark - * @param options - Benchmark configuration (compressors to run, iterations, callbacks, and metric options) - * @returns A FileResult containing the file path, original size in bytes and human-readable form, and an array of CompressorMetrics for each attempted compressor + * @param options - Benchmark configuration + * @returns FileResult with original size and per-compressor metrics */ async function benchmarkFile( file: string, @@ -94,6 +266,15 @@ async function benchmarkFile( }; } +/** + * Benchmark a single compressor against a single file. + * + * @param file - Input file path + * @param name - Compressor name + * @param options - Benchmark options + * @param originalSizeBytes - Original file size for reduction calculation + * @returns CompressorMetrics with timing and size data + */ async function benchmarkCompressor( file: string, name: string, @@ -103,96 +284,59 @@ async function benchmarkCompressor( const compressor = await loadCompressor(name); if (!compressor) { - return { - compressor: name, - sizeBytes: 0, - size: "0 B", - timeMs: 0, - reductionPercent: 0, - success: false, - error: "Compressor not found or not installed", - }; + return createErrorMetrics( + name, + "Compressor not found or not installed" + ); } const iterations = options.iterations || 1; const warmup = options.warmup ?? (iterations > 1 ? 1 : 0); - const times: number[] = []; const tempFiles: string[] = []; const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; try { const warmupFile = `${file}.warmup.${uniqueId}.tmp`; - for (let i = 0; i < warmup; i++) { - await minify({ - compressor, - input: file, - output: warmupFile, - ...(options.type && { type: options.type as "js" | "css" }), - options: options.compressorOptions, - }); - } if (warmup > 0) { + await runWarmup(file, compressor, warmupFile, warmup, options); tempFiles.push(warmupFile); } - let lastOutputFile = ""; - for (let i = 0; i < iterations; i++) { - const start = performance.now(); - lastOutputFile = `${file}.${name}.${uniqueId}.tmp`; - await minify({ - compressor, - input: file, - output: lastOutputFile, - ...(options.type && { type: options.type as "js" | "css" }), - options: options.compressorOptions, - }); - times.push(performance.now() - start); - } - tempFiles.push(lastOutputFile); - - const avgTime = times.reduce((a, b) => a + b, 0) / times.length; - const outStats = statSync(lastOutputFile); - const sizeBytes = outStats.size; - - const metrics: CompressorMetrics = { - compressor: name, - sizeBytes, - size: prettyBytes(sizeBytes), - timeMs: avgTime, - timeMinMs: Math.min(...times), - timeMaxMs: Math.max(...times), - iterationTimes: options.verbose ? times : undefined, - reductionPercent: calculateReduction(originalSizeBytes, sizeBytes), - success: true, - }; - - if (options.includeGzip) { - metrics.gzipSize = await getFilesizeGzippedInBytes(lastOutputFile); - metrics.gzipBytes = await getFilesizeGzippedRaw(lastOutputFile); - } + const outputFile = `${file}.${name}.${uniqueId}.tmp`; + const { times } = await runIterations( + file, + compressor, + outputFile, + iterations, + options + ); + tempFiles.push(outputFile); - if (options.includeBrotli) { - metrics.brotliSize = await getFilesizeBrotliInBytes(lastOutputFile); - } + const metrics = await calculateCompressorMetrics( + name, + times, + outputFile, + originalSizeBytes, + options + ); cleanupTempFiles(tempFiles); - return metrics; } catch (err) { cleanupTempFiles(tempFiles); - - return { - compressor: name, - sizeBytes: 0, - size: "0 B", - timeMs: 0, - reductionPercent: 0, - success: false, - error: err instanceof Error ? err.message : String(err), - }; + return createErrorMetrics( + name, + err instanceof Error ? err.message : String(err) + ); } } +/** + * Calculate summary statistics from all file results. + * + * @param files - Array of FileResult objects + * @returns Summary with best compression, performance, and recommended compressor + */ function calculateSummary(files: FileResult[]): BenchmarkResult["summary"] { const allResults = files.flatMap((f) => f.results).filter((r) => r.success); @@ -230,11 +374,3 @@ function calculateSummary(files: FileResult[]): BenchmarkResult["summary"] { recommended, }; } - -function cleanupTempFiles(files: string[]): void { - for (const file of files) { - try { - unlinkSync(file); - } catch {} - } -} diff --git a/packages/utils/__tests__/compressor-resolver.test.ts b/packages/utils/__tests__/compressor-resolver.test.ts index 25a9cca58..e6f1fc26e 100644 --- a/packages/utils/__tests__/compressor-resolver.test.ts +++ b/packages/utils/__tests__/compressor-resolver.test.ts @@ -11,7 +11,11 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { getKnownExportName, isBuiltInCompressor, + isLocalPath, resolveCompressor, + tryResolveBuiltIn, + tryResolveLocalFile, + tryResolveNpmPackage, } from "../src/compressor-resolver.ts"; import { deleteFile, writeFile } from "../src/index.ts"; @@ -36,6 +40,134 @@ describe("Package: utils/compressor-resolver", () => { vi.resetModules(); }); + describe("isLocalPath", () => { + test("should return true for relative paths with ./", () => { + expect(isLocalPath("./compressor.js")).toBe(true); + expect(isLocalPath("./path/to/file.ts")).toBe(true); + }); + + test("should return true for parent relative paths with ../", () => { + expect(isLocalPath("../compressor.js")).toBe(true); + expect(isLocalPath("../../file.mjs")).toBe(true); + }); + + test("should return true for absolute POSIX paths", () => { + expect(isLocalPath("/usr/local/compressor.js")).toBe(true); + expect(isLocalPath("/compressor.js")).toBe(true); + }); + + test("should return true for Windows absolute paths", () => { + expect(isLocalPath("C:\\Users\\file.js")).toBe(true); + expect(isLocalPath("D:/Projects/comp.ts")).toBe(true); + }); + + test("should return false for npm package names", () => { + expect(isLocalPath("terser")).toBe(false); + expect(isLocalPath("@node-minify/terser")).toBe(false); + expect(isLocalPath("my-package")).toBe(false); + }); + }); + + describe("tryResolveBuiltIn", () => { + test("should resolve known built-in compressor", async () => { + const result = await tryResolveBuiltIn("terser"); + expect(result).not.toBeNull(); + expect(result?.compressor).toBeTypeOf("function"); + expect(result?.label).toBe("terser"); + expect(result?.isBuiltIn).toBe(true); + }); + + test("should return null for unknown compressor name", async () => { + const result = await tryResolveBuiltIn("unknown-compressor"); + expect(result).toBeNull(); + }); + + test("should return null for npm package name", async () => { + const result = await tryResolveBuiltIn("picocolors"); + expect(result).toBeNull(); + }); + + test("should return null when built-in package has no valid compressor export", async () => { + // Mock import to return a module with no function exports + vi.doMock("@node-minify/terser", () => ({ + notAFunction: "string value", + anotherValue: 42, + })); + + // Re-import to pick up the mock + const { tryResolveBuiltIn: freshTryResolve } = await import( + "../src/compressor-resolver.ts" + ); + const result = await freshTryResolve("terser"); + expect(result).toBeNull(); + + // Restore mock so subsequent tests are unaffected + vi.doUnmock("@node-minify/terser"); + }); + }); + + describe("tryResolveNpmPackage", () => { + test("should resolve installed npm package with function export", async () => { + const result = await tryResolveNpmPackage("picocolors"); + expect(result).not.toBeNull(); + expect(result?.compressor).toBeTypeOf("function"); + expect(result?.isBuiltIn).toBe(false); + }); + + test("should return null for non-existent package", async () => { + const result = await tryResolveNpmPackage( + "non-existent-package-xyz-123" + ); + expect(result).toBeNull(); + }); + + test("should throw for package without valid compressor export", async () => { + await expect( + tryResolveNpmPackage("@changesets/types") + ).rejects.toThrow("doesn't export a valid compressor function"); + }); + }); + + describe("tryResolveLocalFile", () => { + test("should return null for non-local paths", async () => { + const result = await tryResolveLocalFile("terser"); + expect(result).toBeNull(); + }); + + test("should resolve local file with default export", async () => { + const localPath = path.join(tmpDir, "try-resolve-local.mjs"); + filesToCleanup.add(localPath); + writeFile({ + file: localPath, + content: `export default async function({ content }) { return { code: content }; }`, + }); + + const result = await tryResolveLocalFile(localPath); + expect(result).not.toBeNull(); + expect(result?.compressor).toBeTypeOf("function"); + expect(result?.isBuiltIn).toBe(false); + }); + + test("should throw for non-existent local file", async () => { + await expect( + tryResolveLocalFile("./non-existent-file-xyz.js") + ).rejects.toThrow("Could not load local compressor"); + }); + + test("should throw for local file without valid export", async () => { + const localPath = path.join(tmpDir, "no-valid-export.mjs"); + filesToCleanup.add(localPath); + writeFile({ + file: localPath, + content: `export const notAFunction = 42;`, + }); + + await expect(tryResolveLocalFile(localPath)).rejects.toThrow( + "doesn't export a valid compressor function" + ); + }); + }); + describe("isBuiltInCompressor", () => { test("should return true for known compressors", () => { expect(isBuiltInCompressor("terser")).toBe(true); diff --git a/packages/utils/__tests__/utils.test.ts b/packages/utils/__tests__/utils.test.ts index bdf90821e..ab8d308e2 100644 --- a/packages/utils/__tests__/utils.test.ts +++ b/packages/utils/__tests__/utils.test.ts @@ -28,6 +28,7 @@ import { ensureStringContent, getContentFromFiles, getFilesizeBrotliInBytes, + getFilesizeBrotliRaw, getFilesizeGzippedInBytes, getFilesizeGzippedRaw, getFilesizeInBytes, @@ -570,6 +571,27 @@ describe("Package: utils", () => { }); }); + describe("getFilesizeBrotliRaw", () => { + test("should return file size as number", async () => { + const size = await getFilesizeBrotliRaw(fixtureFile); + expect(typeof size).toBe("number"); + expect(size).toBeGreaterThan(0); + }); + + test("should throw if file does not exist", async () => { + await expect(getFilesizeBrotliRaw("fake.js")).rejects.toThrow( + FileOperationError + ); + }); + + test("should throw if path is a directory", async () => { + const dirPath = __dirname || "."; + await expect(getFilesizeBrotliRaw(dirPath)).rejects.toThrow( + FileOperationError + ); + }); + }); + describe("getContentFromFiles", () => { test("should return content from a single file", () => { const content = getContentFromFiles(fixtureFile); diff --git a/packages/utils/src/compressor-resolver.ts b/packages/utils/src/compressor-resolver.ts index db171c75c..0917f45a5 100644 --- a/packages/utils/src/compressor-resolver.ts +++ b/packages/utils/src/compressor-resolver.ts @@ -60,27 +60,23 @@ export type CompressorResolution = { /** * Determines whether a string represents a local file path. * - * Recognizes POSIX-style relative or absolute paths starting with "./", "../", or "/", - * and Windows absolute paths like "C:\\" or "C:/". - * * @param name - The path string to test * @returns `true` if `name` appears to be a local file path, `false` otherwise */ -function isLocalPath(name: string): boolean { +export function isLocalPath(name: string): boolean { return ( name.startsWith("./") || name.startsWith("../") || name.startsWith("/") || - /^[a-zA-Z]:[\\/]/.test(name) // Windows absolute path + /^[a-zA-Z]:[/\\]/.test(name) ); } /** * Converts a package name to camelCase for export lookup. * - * Examples: "my-tool" -> "myTool", "some_pkg" -> "somePkg" - * - * @returns The input `name` converted to camelCase where characters following `-` or `_` are uppercased + * @param name - The package or file name to convert + * @returns The input `name` converted to camelCase */ function toCamelCase(name: string): string { return name.replace(/[-_](.)/g, (_, char) => char.toUpperCase()); @@ -89,13 +85,6 @@ function toCamelCase(name: string): string { /** * Resolve a compressor function exported by a loaded module. * - * Searches the module's exports in this priority order to locate a usable compressor: - * 1. Known built-in export for the given name - * 2. CamelCase export derived from the package/base name - * 3. Named export `compressor` - * 4. Default export - * 5. First export whose value is a function - * * @param mod - The imported module object to inspect for exports * @param name - The package or file name used to derive known and camelCase export names * @returns The resolved `Compressor` function if found, or `null` if no suitable function export exists @@ -104,13 +93,11 @@ function extractCompressor( mod: Record, name: string ): Compressor | null { - // 1. Check known exports first const knownExport = KNOWN_COMPRESSOR_EXPORTS[name]; if (knownExport && typeof mod[knownExport] === "function") { return mod[knownExport] as Compressor; } - // 2. Try camelCase of package name const baseName = name.includes("/") ? (name.split("/").pop() ?? name) : name; @@ -119,17 +106,14 @@ function extractCompressor( return mod[camelName] as Compressor; } - // 3. Try "compressor" named export if (typeof mod.compressor === "function") { return mod.compressor as Compressor; } - // 4. Try default export if (typeof mod.default === "function") { return mod.default as Compressor; } - // 5. Find first function export for (const value of Object.values(mod)) { if (typeof value === "function") { return value as Compressor; @@ -142,52 +126,60 @@ function extractCompressor( /** * Create a display label from a compressor package name or a local file path. * - * @param name - Compressor npm package name or a local file path (./, ../, /, or Windows absolute). - * @returns The package name for npm compressors, or the local file's basename without its .js/.ts/.mjs/.cjs extension. + * @param name - Compressor npm package name or a local file path + * @returns The package name for npm compressors, or the local file's basename without extension */ function generateLabel(name: string): string { if (isLocalPath(name)) { - // For local paths, use the filename without extension return path.basename(name).replace(/\.(js|ts|mjs|cjs)$/, ""); } - // For npm packages, use as-is return name; } /** - * Resolve a compressor by name from a built-in @node-minify package, an installed npm package, or a local file path. + * Try to resolve a compressor from a built-in @node-minify package. * - * @param name - Compressor identifier: a built-in name (e.g., "terser"), an npm package name, or a local path (e.g., "./compressor.js") - * @returns The resolved CompressorResolution containing the compressor function, a display `label`, and `isBuiltIn` flag - * @throws Error if the compressor cannot be found or the module does not export a valid compressor function + * @param name - The compressor name (e.g., "terser", "esbuild") + * @returns The resolved CompressorResolution if found, or `null` if not installed/available */ -export async function resolveCompressor( +export async function tryResolveBuiltIn( name: string -): Promise { - const isKnown = name in KNOWN_COMPRESSOR_EXPORTS; +): Promise { + if (!(name in KNOWN_COMPRESSOR_EXPORTS)) { + return null; + } - // 1. Try built-in @node-minify package - if (isKnown) { - try { - const mod = (await import(`@node-minify/${name}`)) as Record< - string, - unknown - >; - const compressor = extractCompressor(mod, name); + try { + const mod = (await import(`@node-minify/${name}`)) as Record< + string, + unknown + >; + const compressor = extractCompressor(mod, name); - if (compressor) { - return { - compressor, - label: name, - isBuiltIn: true, - }; - } - } catch { - // Built-in package not installed, will try as external + if (compressor) { + return { + compressor, + label: name, + isBuiltIn: true, + }; } + } catch { + // Built-in package not installed } - // 2. Try as npm package + return null; +} + +/** + * Try to resolve a compressor from an npm package. + * + * @param name - The npm package name + * @returns The resolved CompressorResolution if found, or `null` if not resolvable + * @throws Error if the package is found but doesn't export a valid compressor + */ +export async function tryResolveNpmPackage( + name: string +): Promise { try { const mod = (await import(name)) as Record; const compressor = extractCompressor(mod, name); @@ -206,57 +198,101 @@ export async function resolveCompressor( `named export '${toCamelCase(name)}'.` ); } catch (error) { - // If it's our error about invalid exports, rethrow if ( error instanceof Error && error.message.includes("doesn't export a valid compressor") ) { throw error; } + return null; + } +} - // 3. Try as local file path - if (isLocalPath(name)) { - try { - const absolutePath = path.resolve(process.cwd(), name); - const fileUrl = pathToFileURL(absolutePath).href; - const mod = (await import(fileUrl)) as Record; - const compressor = extractCompressor(mod, name); +/** + * Try to resolve a compressor from a local file path. + * + * @param name - The local file path (e.g., "./my-compressor.js") + * @returns The resolved CompressorResolution if found, or `null` if not a local path + * @throws Error if the file is found but doesn't export a valid compressor + */ +export async function tryResolveLocalFile( + name: string +): Promise { + if (!isLocalPath(name)) { + return null; + } - if (compressor) { - return { - compressor, - label: generateLabel(name), - isBuiltIn: false, - }; - } + try { + const absolutePath = path.resolve(process.cwd(), name); + const fileUrl = pathToFileURL(absolutePath).href; + const mod = (await import(fileUrl)) as Record; + const compressor = extractCompressor(mod, name); - throw new Error( - `Local file '${name}' doesn't export a valid compressor function. ` + - `Expected a function as default export or named export 'compressor'.` - ); - } catch (localError) { - if ( - localError instanceof Error && - localError.message.includes( - "doesn't export a valid compressor" - ) - ) { - throw localError; - } - throw new Error( - `Could not load local compressor '${name}'. ` + - `File not found or failed to import: ${localError instanceof Error ? localError.message : String(localError)}` - ); - } + if (compressor) { + return { + compressor, + label: generateLabel(name), + isBuiltIn: false, + }; } throw new Error( - `Could not resolve compressor '${name}'. ` + - `Is it installed? For local files, use a path starting with './' or '/'.` + `Local file '${name}' doesn't export a valid compressor function. ` + + `Expected a function as default export or named export 'compressor'.` + ); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("doesn't export a valid compressor") + ) { + throw error; + } + throw new Error( + `Could not load local compressor '${name}'. ` + + `File not found or failed to import: ${error instanceof Error ? error.message : String(error)}` ); } } +/** + * Resolve a compressor by name from a built-in @node-minify package, an installed npm package, or a local file path. + * + * @param name - Compressor identifier: a built-in name (e.g., "terser"), an npm package name, or a local path (e.g., "./compressor.js") + * @returns The resolved CompressorResolution containing the compressor function, a display `label`, and `isBuiltIn` flag + * @throws Error if the compressor cannot be found or the module does not export a valid compressor function + */ +export async function resolveCompressor( + name: string +): Promise { + // 1. Try built-in @node-minify package + const builtIn = await tryResolveBuiltIn(name); + if (builtIn) { + return builtIn; + } + + // 2. Try as npm package + const npmPackage = await tryResolveNpmPackage(name); + if (npmPackage) { + return npmPackage; + } + + // 3. Try as local file path (throws if file exists but has invalid exports) + if (isLocalPath(name)) { + const localFile = await tryResolveLocalFile(name); + if (localFile) { + return localFile; + } + // tryResolveLocalFile throws for local paths that can't be loaded, + // so reaching here means it returned null (which shouldn't happen + // after the isLocalPath guard, but we handle it defensively) + } + + throw new Error( + `Could not resolve compressor '${name}'. ` + + `Is it installed? For local files, use a path starting with './' or '/'.` + ); +} + /** * Determines whether a compressor name corresponds to a known built-in compressor. * diff --git a/packages/utils/src/getFilesizeBrotliInBytes.ts b/packages/utils/src/getFilesizeBrotliInBytes.ts index 0b6c645c0..a27cae3e3 100644 --- a/packages/utils/src/getFilesizeBrotliInBytes.ts +++ b/packages/utils/src/getFilesizeBrotliInBytes.ts @@ -14,32 +14,75 @@ import { prettyBytes } from "./prettyBytes.ts"; const brotliCompressAsync = promisify(brotliCompress); +/** + * Compute the brotli-compressed size of a file in bytes. + * + * @param file - Path to the file to measure + * @returns The brotli-compressed size in bytes + * @throws FileOperationError if the file does not exist or the path is not a valid file + * @internal + */ +async function getBrotliSize(file: string): Promise { + if (!existsSync(file)) { + throw new FileOperationError( + "access", + file, + new Error("File does not exist") + ); + } + + if (!isValidFile(file)) { + throw new FileOperationError( + "access", + file, + new Error("Path is not a valid file") + ); + } + + const content = await readFile(file); + const compressed = await brotliCompressAsync(content, { + params: { + [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, + }, + }); + + return compressed.length; +} + +/** + * Get the brotli-compressed size of a file as a human-readable string. + * + * @param file - Path to the file + * @returns The brotli-compressed size formatted for display (for example, "1.5 kB") + */ export async function getFilesizeBrotliInBytes(file: string): Promise { try { - if (!existsSync(file)) { - throw new FileOperationError( - "access", - file, - new Error("File does not exist") - ); - } - - if (!isValidFile(file)) { - throw new FileOperationError( - "access", - file, - new Error("Path is not a valid file") - ); + const size = await getBrotliSize(file); + return prettyBytes(size); + } catch (error) { + if (error instanceof FileOperationError) { + throw error; } + throw new FileOperationError( + "get brotli size of", + file, + error as Error + ); + } +} - const content = await readFile(file); - const compressed = await brotliCompressAsync(content, { - params: { - [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, - }, - }); - - return prettyBytes(compressed.length); +/** + * Get the brotli-compressed file size in bytes. + * + * @param file - Path to the file + * @returns Brotli-compressed file size in bytes + * @example + * const bytes = await getFilesizeBrotliRaw('bundle.js') + * console.log(bytes) // 12583 + */ +export async function getFilesizeBrotliRaw(file: string): Promise { + try { + return await getBrotliSize(file); } catch (error) { if (error instanceof FileOperationError) { throw error; diff --git a/packages/utils/src/getFilesizeGzippedInBytes.ts b/packages/utils/src/getFilesizeGzippedInBytes.ts index f481c2d09..7f88baf8c 100644 --- a/packages/utils/src/getFilesizeGzippedInBytes.ts +++ b/packages/utils/src/getFilesizeGzippedInBytes.ts @@ -51,6 +51,9 @@ export async function getFilesizeGzippedInBytes(file: string): Promise { const size = await getGzipSize(file); return prettyBytes(size); } catch (error) { + if (error instanceof FileOperationError) { + throw error; + } throw new FileOperationError( "get gzipped size of", file, @@ -71,6 +74,9 @@ export async function getFilesizeGzippedRaw(file: string): Promise { try { return await getGzipSize(file); } catch (error) { + if (error instanceof FileOperationError) { + throw error; + } throw new FileOperationError( "get gzipped size of", file, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 672dd9b46..206d48948 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -2,7 +2,11 @@ import { buildArgs, toBuildArgsOptions } from "./buildArgs.ts"; import { getKnownExportName, isBuiltInCompressor, + isLocalPath, resolveCompressor, + tryResolveBuiltIn, + tryResolveLocalFile, + tryResolveNpmPackage, } from "./compressor-resolver.ts"; import { compressSingleFile } from "./compressSingleFile.ts"; import { deleteFile } from "./deleteFile.ts"; @@ -13,7 +17,10 @@ import { getContentFromFiles, getContentFromFilesAsync, } from "./getContentFromFiles.ts"; -import { getFilesizeBrotliInBytes } from "./getFilesizeBrotliInBytes.ts"; +import { + getFilesizeBrotliInBytes, + getFilesizeBrotliRaw, +} from "./getFilesizeBrotliInBytes.ts"; import { getFilesizeGzippedInBytes, getFilesizeGzippedRaw, @@ -38,12 +45,14 @@ export { getContentFromFiles, getContentFromFilesAsync, getFilesizeBrotliInBytes, + getFilesizeBrotliRaw, getFilesizeGzippedInBytes, getFilesizeGzippedRaw, getFilesizeInBytes, getKnownExportName, isBuiltInCompressor, isImageFile, + isLocalPath, isValidFile, isValidFileAsync, prettyBytes, @@ -55,6 +64,9 @@ export { setFileNameMin, setPublicFolder, toBuildArgsOptions, + tryResolveBuiltIn, + tryResolveLocalFile, + tryResolveNpmPackage, validateMinifyResult, warnDeprecation, wildcards,