From 989fd4b4c051507bf97a251ef80d0f56a6c28c5c Mon Sep 17 00:00:00 2001 From: Arno V Date: Sat, 31 Jan 2026 14:10:32 -0500 Subject: [PATCH] feat(bundlecheck): Add platform support (browser/node) Introduce platform targeting for bundle builds: add --platform/-p flag (auto|browser|node) with alias support and validation. Implement normalizePlatform and isValidPlatform helpers, wire platform through CLI parsing, bundlecheck, bundler and trend logic, and pass platform to esbuild. Auto-detect platform from package.json engines when platform is "auto"/undefined; treat packages with engines.node (and no engines.browser) as node. For node platform gzip sizes are not calculated (gzipSize becomes null) and UIs/tests updated accordingly. Update README and add/adjust tests to cover normalization, auto-detection, rendering (trend) behavior, and bundle checks. --- packages/bundlecheck/README.md | 51 ++++-- .../bundlecheck/src/__tests__/bundler.test.ts | 146 ++++++++++++++++++ .../src/__tests__/defaults.test.ts | 140 +++++++++++++++++ .../bundlecheck/src/__tests__/trend.test.ts | 100 ++++++++++++ packages/bundlecheck/src/bundlecheck.ts | 15 +- packages/bundlecheck/src/bundler.ts | 37 ++++- packages/bundlecheck/src/defaults.ts | 67 ++++++++ packages/bundlecheck/src/parse.ts | 25 ++- packages/bundlecheck/src/trend.ts | 91 +++++++---- 9 files changed, 616 insertions(+), 56 deletions(-) create mode 100644 packages/bundlecheck/src/__tests__/defaults.test.ts diff --git a/packages/bundlecheck/README.md b/packages/bundlecheck/README.md index 7cc1405b..0c425b66 100644 --- a/packages/bundlecheck/README.md +++ b/packages/bundlecheck/README.md @@ -13,6 +13,7 @@ A CLI tool to check the bundle size of npm packages, similar to [bundlephobia.co - Support for checking specific exports (tree-shaking) - Automatic externalization of React and React-DOM - Raw and gzip sizes with configurable compression level +- **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) @@ -92,17 +93,18 @@ bundlecheck lodash "debounce,throttle" ### Options -| Flag | Short | Description | -| ------------------- | ----------- | ------------------------------------------------------- | -| `--help` | `-h` | Display help instructions | -| `--version` | `-v` | Output the current version | -| `--versions` | `-V` | Choose from available package versions interactively | -| `--trend [N]` | `-t [N]` | Show bundle size trend for N versions (default: 5) | -| `--boring` | `-b` | Do not use color output | -| `--gzipLevel ` | `-g ` | Gzip compression level (1-9, default: 5) | -| `--external ` | `-e ` | Comma-separated additional packages to mark as external | -| `--noExternal` | `-n` | Do not mark any packages as external | -| `--registry ` | `-r ` | Custom npm registry URL (default: registry.npmjs.org) | +| Flag | Short | Description | +| ------------------- | ----------- | ---------------------------------------------------------------- | +| `--help` | `-h` | Display help instructions | +| `--version` | `-v` | Output the current version | +| `--versions` | `-V` | Choose from available package versions interactively | +| `--trend [N]` | `-t [N]` | Show bundle size trend for N versions (default: 5) | +| `--boring` | `-b` | Do not use color output | +| `--gzipLevel ` | `-g ` | Gzip compression level (1-9, default: 5) | +| `--external ` | `-e ` | Comma-separated additional packages to mark as external | +| `--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` | ### Examples @@ -137,6 +139,12 @@ bundlecheck lodash --trend 3 # Use a custom npm registry bundlecheck @myorg/private-pkg --registry https://npm.mycompany.com + +# Check a Node.js package (explicit platform) +bundlecheck express --platform node + +# Auto-detect platform (default behavior) +bundlecheck express # auto-detects "node" from package.json engines ``` ## How It Works @@ -148,6 +156,27 @@ bundlecheck @myorg/private-pkg --registry https://npm.mycompany.com 5. Reports raw and gzip sizes 6. Cleans up temporary files +## Platform Support + +The `--platform` flag controls how the bundle is built: + +- **`auto`** (default): Automatically detects the platform from the package's `engines` field. If the package specifies `engines.node` without `engines.browser`, it uses `node`; otherwise defaults to `browser`. +- **`browser`**: Builds for browser environments (also accepts aliases: `web`, `desktop`, `client`) +- **`node`**: Builds for Node.js environments (also accepts aliases: `server`, `nodejs`, `backend`) + +When targeting **node** platform: +- Gzip size is not calculated (shows "N/A") since server-side code isn't typically served compressed over HTTP +- The bundle is optimized for Node.js built-ins + +```bash +# Auto-detect (recommended for most cases) +bundlecheck express # detects "node" from engines.node + +# Explicit platform +bundlecheck lodash --platform browser +bundlecheck fastify -p server # "server" is an alias for "node" +``` + ## Default Externals By default, `react` and `react-dom` are marked as external (not included in the bundle size) since most React-based packages expect these as peer dependencies. This matches how these packages would typically be used in a real application. diff --git a/packages/bundlecheck/src/__tests__/bundler.test.ts b/packages/bundlecheck/src/__tests__/bundler.test.ts index 1c0556eb..f1b5de78 100644 --- a/packages/bundlecheck/src/__tests__/bundler.test.ts +++ b/packages/bundlecheck/src/__tests__/bundler.test.ts @@ -893,4 +893,150 @@ describe("checkBundleSize", () => { expect(result).toBeDefined(); }); + + describe("platform option", () => { + it("should default to browser platform", async () => { + const result = await checkBundleSize({ + packageName: "test-package", + }); + + expect(result.platform).toBe("browser"); + }); + + it("should use explicit browser platform", async () => { + const result = await checkBundleSize({ + packageName: "test-package", + platform: "browser", + }); + + expect(result.platform).toBe("browser"); + expect(result.gzipSize).not.toBeNull(); + }); + + it("should use explicit node platform", async () => { + const result = await checkBundleSize({ + packageName: "test-package", + platform: "node", + }); + + expect(result.platform).toBe("node"); + }); + + it("should return null gzipSize for node platform", async () => { + const result = await checkBundleSize({ + packageName: "test-package", + platform: "node", + }); + + expect(result.gzipSize).toBeNull(); + }); + + it("should return gzipSize for browser platform", async () => { + const result = await checkBundleSize({ + packageName: "test-package", + platform: "browser", + }); + + expect(result.gzipSize).not.toBeNull(); + expect(result.gzipSize).toBeGreaterThan(0); + }); + + it("should auto-detect node platform from engines.node", async () => { + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + name: "test-package", + version: "1.0.0", + main: "./dist/index.js", + dependencies: {}, + peerDependencies: {}, + engines: { + node: ">=18.0.0", + }, + }), + ); + + const result = await checkBundleSize({ + packageName: "test-package", + }); + + expect(result.platform).toBe("node"); + expect(result.gzipSize).toBeNull(); + }); + + it("should default to browser when no engines specified", async () => { + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + name: "test-package", + version: "1.0.0", + main: "./dist/index.js", + dependencies: {}, + peerDependencies: {}, + }), + ); + + const result = await checkBundleSize({ + packageName: "test-package", + }); + + expect(result.platform).toBe("browser"); + }); + + it("should use explicit platform even when engines.node is present", async () => { + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + name: "test-package", + version: "1.0.0", + main: "./dist/index.js", + dependencies: {}, + peerDependencies: {}, + engines: { + node: ">=18.0.0", + }, + }), + ); + + const result = await checkBundleSize({ + packageName: "test-package", + platform: "browser", + }); + + expect(result.platform).toBe("browser"); + expect(result.gzipSize).not.toBeNull(); + }); + + it("should pass platform to esbuild", async () => { + await checkBundleSize({ + packageName: "test-package", + platform: "node", + }); + + expect(esbuild.build).toHaveBeenCalledWith( + expect.objectContaining({ + platform: "node", + }), + ); + }); + + it("should not auto-detect node when engines.browser is also present", async () => { + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + name: "test-package", + version: "1.0.0", + main: "./dist/index.js", + dependencies: {}, + peerDependencies: {}, + engines: { + node: ">=18.0.0", + browser: ">= ES2020", + }, + }), + ); + + const result = await checkBundleSize({ + packageName: "test-package", + }); + + expect(result.platform).toBe("browser"); + }); + }); }); diff --git a/packages/bundlecheck/src/__tests__/defaults.test.ts b/packages/bundlecheck/src/__tests__/defaults.test.ts new file mode 100644 index 00000000..81ea66da --- /dev/null +++ b/packages/bundlecheck/src/__tests__/defaults.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { isValidPlatform, normalizePlatform } from "../defaults.js"; + +describe("normalizePlatform", () => { + describe("auto detection", () => { + it("should return undefined for 'auto'", () => { + expect(normalizePlatform("auto")).toBeUndefined(); + }); + + it("should return undefined for 'AUTO' (case insensitive)", () => { + expect(normalizePlatform("AUTO")).toBeUndefined(); + }); + + it("should return undefined for undefined input", () => { + expect(normalizePlatform(undefined)).toBeUndefined(); + }); + }); + + describe("browser aliases", () => { + it("should normalize 'browser' to 'browser'", () => { + expect(normalizePlatform("browser")).toBe("browser"); + }); + + it("should normalize 'web' to 'browser'", () => { + expect(normalizePlatform("web")).toBe("browser"); + }); + + it("should normalize 'desktop' to 'browser'", () => { + expect(normalizePlatform("desktop")).toBe("browser"); + }); + + it("should normalize 'client' to 'browser'", () => { + expect(normalizePlatform("client")).toBe("browser"); + }); + + it("should handle case insensitivity for browser aliases", () => { + expect(normalizePlatform("BROWSER")).toBe("browser"); + expect(normalizePlatform("Web")).toBe("browser"); + expect(normalizePlatform("DESKTOP")).toBe("browser"); + expect(normalizePlatform("CLIENT")).toBe("browser"); + }); + + it("should handle whitespace for browser aliases", () => { + expect(normalizePlatform(" browser ")).toBe("browser"); + expect(normalizePlatform(" web ")).toBe("browser"); + }); + }); + + describe("node aliases", () => { + it("should normalize 'node' to 'node'", () => { + expect(normalizePlatform("node")).toBe("node"); + }); + + it("should normalize 'server' to 'node'", () => { + expect(normalizePlatform("server")).toBe("node"); + }); + + it("should normalize 'nodejs' to 'node'", () => { + expect(normalizePlatform("nodejs")).toBe("node"); + }); + + it("should normalize 'backend' to 'node'", () => { + expect(normalizePlatform("backend")).toBe("node"); + }); + + it("should handle case insensitivity for node aliases", () => { + expect(normalizePlatform("NODE")).toBe("node"); + expect(normalizePlatform("Server")).toBe("node"); + expect(normalizePlatform("NODEJS")).toBe("node"); + expect(normalizePlatform("Backend")).toBe("node"); + }); + + it("should handle whitespace for node aliases", () => { + expect(normalizePlatform(" node ")).toBe("node"); + expect(normalizePlatform(" server ")).toBe("node"); + }); + }); + + describe("invalid values", () => { + it("should return the invalid value as-is (to be caught by validation)", () => { + expect(normalizePlatform("invalid")).toBe("invalid"); + expect(normalizePlatform("unknown")).toBe("unknown"); + }); + }); +}); + +describe("isValidPlatform", () => { + describe("valid values", () => { + it("should accept undefined", () => { + expect(isValidPlatform(undefined)).toBe(true); + }); + + it("should accept 'auto'", () => { + expect(isValidPlatform("auto")).toBe(true); + }); + + it("should accept browser aliases", () => { + expect(isValidPlatform("browser")).toBe(true); + expect(isValidPlatform("web")).toBe(true); + expect(isValidPlatform("desktop")).toBe(true); + expect(isValidPlatform("client")).toBe(true); + }); + + it("should accept node aliases", () => { + expect(isValidPlatform("node")).toBe(true); + expect(isValidPlatform("server")).toBe(true); + expect(isValidPlatform("nodejs")).toBe(true); + expect(isValidPlatform("backend")).toBe(true); + }); + + it("should be case insensitive", () => { + expect(isValidPlatform("BROWSER")).toBe(true); + expect(isValidPlatform("Node")).toBe(true); + expect(isValidPlatform("AUTO")).toBe(true); + }); + + it("should handle whitespace", () => { + expect(isValidPlatform(" browser ")).toBe(true); + expect(isValidPlatform(" node ")).toBe(true); + }); + }); + + describe("invalid values", () => { + it("should reject invalid platform names", () => { + expect(isValidPlatform("invalid")).toBe(false); + expect(isValidPlatform("neutral")).toBe(false); + expect(isValidPlatform("windows")).toBe(false); + expect(isValidPlatform("linux")).toBe(false); + }); + + it("should reject empty string", () => { + expect(isValidPlatform("")).toBe(false); + }); + + it("should reject misspellings", () => { + expect(isValidPlatform("browsr")).toBe(false); // cSpell:ignore browsr + expect(isValidPlatform("nod")).toBe(false); + }); + }); +}); diff --git a/packages/bundlecheck/src/__tests__/trend.test.ts b/packages/bundlecheck/src/__tests__/trend.test.ts index 82f365d5..b4c32a3c 100644 --- a/packages/bundlecheck/src/__tests__/trend.test.ts +++ b/packages/bundlecheck/src/__tests__/trend.test.ts @@ -333,6 +333,70 @@ describe("renderTrendGraph", () => { // Boring lines should not contain escape codes expect(boringLines.some((l) => l.includes("\x1b["))).toBe(false); }); + + describe("null gzipSize (node platform)", () => { + it("should skip gzip section when all gzipSize values are null", () => { + const results: TrendResult[] = [ + { version: "2.0.0", rawSize: 2048, gzipSize: null }, + { version: "1.0.0", rawSize: 1024, gzipSize: null }, + ]; + + const lines = renderTrendGraph("test-package", results, true); + + // Should NOT contain Gzip Size section + expect(lines.some((l) => l.includes("Gzip Size:"))).toBe(false); + // Should still contain Raw Size section + expect(lines.some((l) => l.includes("Raw Size:"))).toBe(true); + }); + + it("should skip gzip change summary when gzipSize is null", () => { + const results: TrendResult[] = [ + { version: "2.0.0", rawSize: 2048, gzipSize: null }, + { version: "1.0.0", rawSize: 1024, gzipSize: null }, + ]; + + const lines = renderTrendGraph("test-package", results, true); + + // Should show change summary + expect(lines.some((l) => l.includes("Change from"))).toBe(true); + // Should show raw change + expect(lines.some((l) => l.includes("Raw:"))).toBe(true); + // Should NOT show gzip change + expect(lines.some((l) => l.includes("Gzip:"))).toBe(false); + }); + + it("should show only raw size for single result with null gzipSize", () => { + const results: TrendResult[] = [ + { version: "1.0.0", rawSize: 1024, gzipSize: null }, + ]; + + const lines = renderTrendGraph("test-package", results, true); + + expect(lines.some((l) => l.includes("Raw Size:"))).toBe(true); + expect(lines.some((l) => l.includes("Gzip Size:"))).toBe(false); + expect(lines.some((l) => l.includes("1.0.0"))).toBe(true); + }); + + it("should render raw size bars correctly when gzipSize is null", () => { + const results: TrendResult[] = [ + { version: "3.0.0", rawSize: 3000, gzipSize: null }, + { version: "2.0.0", rawSize: 2000, gzipSize: null }, + { version: "1.0.0", rawSize: 1000, gzipSize: null }, + ]; + + const lines = renderTrendGraph("test-package", results, true); + + // Find raw size lines + const rawStartIdx = lines.findIndex((l) => l.includes("Raw Size:")); + const rawLines = lines + .slice(rawStartIdx + 1) + .filter((l) => l.includes("▇")); + + // Should have bars with different lengths + const barCounts = rawLines.map((l) => (l.match(/▇/g) || []).length); + expect(new Set(barCounts).size).toBeGreaterThan(1); + }); + }); }); describe("analyzeTrend", () => { @@ -353,6 +417,7 @@ describe("analyzeTrend", () => { gzipLevel: 9, externals: [], dependencies: [], + platform: "browser", }) .mockResolvedValueOnce({ packageName: "test@1.0.0", @@ -363,6 +428,7 @@ describe("analyzeTrend", () => { gzipLevel: 9, externals: [], dependencies: [], + platform: "browser", }); const results = await analyzeTrend({ @@ -395,6 +461,7 @@ describe("analyzeTrend", () => { gzipLevel: 9, externals: [], dependencies: [], + platform: "browser", }) .mockRejectedValueOnce(new Error("Version not found")) .mockResolvedValueOnce({ @@ -406,6 +473,7 @@ describe("analyzeTrend", () => { gzipLevel: 9, externals: [], dependencies: [], + platform: "browser", }); const results = await analyzeTrend({ @@ -429,6 +497,7 @@ describe("analyzeTrend", () => { gzipLevel: 5, externals: ["lodash"], dependencies: [], + platform: "browser", }); await analyzeTrend({ @@ -439,6 +508,7 @@ describe("analyzeTrend", () => { noExternal: false, gzipLevel: 5, boring: true, + platform: "browser", }); expect(mockCheckBundleSize).toHaveBeenCalledWith({ @@ -447,9 +517,39 @@ describe("analyzeTrend", () => { additionalExternals: ["lodash"], noExternal: false, gzipLevel: 5, + registry: undefined, + platform: "browser", }); }); + it("should pass platform option to checkBundleSize", async () => { + mockCheckBundleSize.mockResolvedValue({ + packageName: "test@1.0.0", + packageVersion: "1.0.0", + exports: [], + rawSize: 1024, + gzipSize: null, + gzipLevel: 5, + externals: [], + dependencies: [], + platform: "node", + }); + + const results = await analyzeTrend({ + packageName: "test", + versions: ["1.0.0"], + boring: true, + platform: "node", + }); + + expect(mockCheckBundleSize).toHaveBeenCalledWith( + expect.objectContaining({ + platform: "node", + }), + ); + expect(results[0].gzipSize).toBeNull(); + }); + it("should return empty array when all versions fail", async () => { mockCheckBundleSize.mockRejectedValue(new Error("All failed")); diff --git a/packages/bundlecheck/src/bundlecheck.ts b/packages/bundlecheck/src/bundlecheck.ts index 36c2d539..a6fd3830 100644 --- a/packages/bundlecheck/src/bundlecheck.ts +++ b/packages/bundlecheck/src/bundlecheck.ts @@ -9,7 +9,7 @@ import { formatBytes, parsePackageSpecifier, } from "./bundler.js"; -import { TREND_VERSION_COUNT } from "./defaults.js"; +import { normalizePlatform, TREND_VERSION_COUNT } from "./defaults.js"; import { config } from "./parse.js"; import { analyzeTrend, @@ -56,6 +56,9 @@ async function main() { .filter(Boolean); } + // Normalize platform from flag (handles aliases like "web" → "browser") + const platform = normalizePlatform(flags?.platform); + // If --trend flag is set, show bundle size trend across versions // --trend alone uses default (5), --trend N uses N versions const trendValue = flags?.trend; @@ -100,6 +103,7 @@ async function main() { gzipLevel: flags?.gzipLevel, boring: flags?.boring, registry: flags?.registry, + platform, }); if (results.length === 0) { @@ -170,12 +174,16 @@ async function main() { noExternal: flags?.noExternal, gzipLevel: flags?.gzipLevel, registry: flags?.registry, + 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})`, @@ -184,7 +192,9 @@ async function main() { : `${blue("Exports:")} * (entire package)`, "", `${blue("Raw size:")} ${formatBytes(result.rawSize)}`, - `${blue("Gzip size:")} ${formatBytes(result.gzipSize)} (level ${result.gzipLevel})`, + 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(", ")}` @@ -192,6 +202,7 @@ async function main() { result.dependencies.length > 0 ? `${blue("Dependencies:")} ${result.dependencies.join(", ")}` : `${blue("Dependencies:")} ${green("none")}`, + `${blue("Platform:")} ${platformLabel}${platformNote}`, ], { borderStyle: "round", diff --git a/packages/bundlecheck/src/bundler.ts b/packages/bundlecheck/src/bundler.ts index 44a272d5..9ad866ba 100644 --- a/packages/bundlecheck/src/bundler.ts +++ b/packages/bundlecheck/src/bundler.ts @@ -81,6 +81,8 @@ export type BundleOptions = { noExternal?: boolean; gzipLevel?: number; registry?: string; + /** Target platform. If undefined, auto-detects from package.json engines */ + platform?: "browser" | "node"; }; export type BundleResult = { @@ -88,10 +90,12 @@ export type BundleResult = { packageVersion: string; exports: string[]; rawSize: number; - gzipSize: number; + /** Gzip size in bytes, or null for node platform (gzip not applicable) */ + gzipSize: number | null; gzipLevel: number; externals: string[]; dependencies: string[]; + platform: "browser" | "node"; }; /** @@ -303,6 +307,7 @@ export type PackageInfo = { peerDependencies: Record; exports: PackageExports | null; hasMainEntry: boolean; + engines: Record | null; }; /** @@ -335,6 +340,7 @@ function getPackageInfo(tmpDir: string, packageName: string): PackageInfo { peerDependencies: pkgJson.peerDependencies || {}, exports: pkgJson.exports || null, hasMainEntry, + engines: pkgJson.engines || null, }; } } catch { @@ -346,6 +352,7 @@ function getPackageInfo(tmpDir: string, packageName: string): PackageInfo { peerDependencies: {}, exports: null, hasMainEntry: true, + engines: null, }; } @@ -485,6 +492,7 @@ export async function checkBundleSize( noExternal, gzipLevel = 5, registry, + platform: explicitPlatform, } = options; // Parse the package specifier to extract name, version, and subpath @@ -524,10 +532,19 @@ export async function checkBundleSize( stdio: "pipe", }); - // Get package info (version, dependencies, peer dependencies, exports) + // Get package info (version, dependencies, peer dependencies, exports, engines) const pkgInfo = getPackageInfo(tmpDir, packageName); const peerDepKeys = Object.keys(pkgInfo.peerDependencies); + // Determine platform: use explicit value if provided, otherwise auto-detect from engines + let platform: "browser" | "node" = "browser"; + if (explicitPlatform) { + platform = explicitPlatform; + } else if (pkgInfo.engines?.node && !pkgInfo.engines?.browser) { + // Package specifies node engine without browser - likely a Node.js package + platform = "node"; + } + // Collect all dependency names (prod + peer) const allDependencies = [ ...new Set([...Object.keys(pkgInfo.dependencies), ...peerDepKeys]), @@ -606,7 +623,7 @@ export async function checkBundleSize( bundle: true, write: false, format: "esm", - platform: "browser", + platform, target: "es2020", minify: true, treeShaking: true, @@ -618,11 +635,14 @@ export async function checkBundleSize( const bundleContent = result.outputFiles[0].contents; const rawSize = bundleContent.length; - // Gzip the bundle - const gzipped = await gzipAsync(Buffer.from(bundleContent), { - level: gzipLevel, - }); - const gzipSize = gzipped.length; + // Gzip the bundle (only for browser platform - not relevant for Node.js) + let gzipSize: number | null = null; + if (platform === "browser") { + const gzipped = await gzipAsync(Buffer.from(bundleContent), { + level: gzipLevel, + }); + gzipSize = gzipped.length; + } // Determine the display name let displayName = packageName; @@ -643,6 +663,7 @@ export async function checkBundleSize( gzipLevel, externals, dependencies: allDependencies, + platform, }; } finally { cleanupTempDir(tmpDir); diff --git a/packages/bundlecheck/src/defaults.ts b/packages/bundlecheck/src/defaults.ts index 9a8fa5de..1f2d3f68 100644 --- a/packages/bundlecheck/src/defaults.ts +++ b/packages/bundlecheck/src/defaults.ts @@ -7,8 +7,75 @@ export const defaultFlags = { noExternal: false, versions: false, registry: "", + platform: "auto", }; +/** + * Normalize platform aliases to canonical esbuild platform values + * - "auto" → undefined (triggers auto-detection based on package engines) + * - Browser aliases: "browser", "web", "desktop", "client" → "browser" + * - Node aliases: "node", "server", "nodejs", "backend" → "node" + */ +export function normalizePlatform( + platform: string | undefined, +): "browser" | "node" | undefined { + if (!platform) { + return undefined; + } + + const normalized = platform.toLowerCase().trim(); + + // Auto-detect from package.json engines + if (normalized === "auto") { + return undefined; + } + + // Node aliases + if (["node", "server", "nodejs", "backend"].includes(normalized)) { + return "node"; + } + + // Browser aliases + if (["browser", "web", "desktop", "client"].includes(normalized)) { + return "browser"; + } + + // Invalid value - will be caught by validation + return normalized as "browser" | "node"; +} + +/** + * Check if a platform value is valid (either canonical or alias) + */ +export function isValidPlatform(platform: string | undefined): boolean { + if (platform === undefined) { + return true; + } + + const normalized = platform.toLowerCase().trim(); + + // Empty string after trim is invalid + if (normalized === "") { + return false; + } + const validValues = [ + // Auto-detect + "auto", + // Browser + "browser", + "web", + "desktop", + "client", + // Node + "node", + "server", + "nodejs", + "backend", + ]; + + return validValues.includes(normalized); +} + export const TREND_VERSION_COUNT = 5; export const DEFAULT_EXTERNALS = ["react", "react-dom"]; diff --git a/packages/bundlecheck/src/parse.ts b/packages/bundlecheck/src/parse.ts index f72d44e9..c7d22e8d 100644 --- a/packages/bundlecheck/src/parse.ts +++ b/packages/bundlecheck/src/parse.ts @@ -1,6 +1,10 @@ /* istanbul ignore file */ import { parser } from "@node-cli/parser"; -import { DEFAULT_EXTERNALS, defaultFlags } from "./defaults.js"; +import { + DEFAULT_EXTERNALS, + defaultFlags, + isValidPlatform, +} from "./defaults.js"; export type Flags = { boring?: boolean; @@ -12,6 +16,7 @@ export type Flags = { external?: string; noExternal?: boolean; registry?: string; + platform?: string; }; export type Parameters = { @@ -75,6 +80,11 @@ export const config: Configuration = parser({ command: "bundlecheck lodash --registry https://registry.example.com", comment: "## Use a custom npm registry", }, + { + command: "bundlecheck express --platform node", + comment: + "## Check bundle size for Node.js platform (aliases: server, nodejs, backend)", + }, ], flags: { gzipLevel: { @@ -130,6 +140,13 @@ export const config: Configuration = parser({ "Custom npm registry URL (default: https://registry.npmjs.org)", type: "string", }, + platform: { + shortFlag: "p", + default: defaultFlags.platform, + description: + 'Target platform: "auto" (default, detects from engines), "browser" or "node"', + type: "string", + }, }, parameters: { package: { @@ -153,6 +170,12 @@ export const config: Configuration = parser({ flags.gzipLevel !== undefined && (flags.gzipLevel < 1 || flags.gzipLevel > 9), }, + { + exit: 1, + message: () => + 'Error: Invalid platform. Use "browser" (or web, desktop, client) or "node" (or server, nodejs, backend)', + test: (flags) => !isValidPlatform(flags.platform), + }, ], usage: true, defaultFlags, diff --git a/packages/bundlecheck/src/trend.ts b/packages/bundlecheck/src/trend.ts index 3917919a..d85387e1 100644 --- a/packages/bundlecheck/src/trend.ts +++ b/packages/bundlecheck/src/trend.ts @@ -7,7 +7,8 @@ import { TREND_VERSION_COUNT } from "./defaults.js"; export type TrendResult = { version: string; rawSize: number; - gzipSize: number; + /** Gzip size in bytes, or null for node platform */ + gzipSize: number | null; }; export type TrendOptions = { @@ -19,6 +20,8 @@ export type TrendOptions = { gzipLevel?: number; boring?: boolean; registry?: string; + /** Target platform. If undefined, auto-detects from package.json engines */ + platform?: "browser" | "node"; }; /** @@ -50,6 +53,7 @@ export async function analyzeTrend( gzipLevel, boring, registry, + platform, } = options; const log = new Logger({ boring }); @@ -67,6 +71,7 @@ export async function analyzeTrend( noExternal, gzipLevel, registry, + platform, }); results.push({ @@ -100,18 +105,23 @@ export function renderTrendGraph( // Color helper (respects boring flag) const blue = (text: string) => (boring ? text : kleur.blue(text)); + // Check if gzip data is available (null for node platform) + const hasGzipData = results.some((r) => r.gzipSize !== null); + // Create maps from formatted string to representative value // This ensures values that display the same get the same bar length const gzipFormattedToValue = new Map(); const rawFormattedToValue = new Map(); for (const result of results) { - const gzipFormatted = formatBytes(result.gzipSize); - const rawFormatted = formatBytes(result.rawSize); - // Use first occurrence as representative value for each formatted string - if (!gzipFormattedToValue.has(gzipFormatted)) { - gzipFormattedToValue.set(gzipFormatted, result.gzipSize); + if (hasGzipData && result.gzipSize !== null) { + const gzipFormatted = formatBytes(result.gzipSize); + // Use first occurrence as representative value for each formatted string + if (!gzipFormattedToValue.has(gzipFormatted)) { + gzipFormattedToValue.set(gzipFormatted, result.gzipSize); + } } + const rawFormatted = formatBytes(result.rawSize); if (!rawFormattedToValue.has(rawFormatted)) { rawFormattedToValue.set(rawFormatted, result.rawSize); } @@ -121,8 +131,8 @@ export function renderTrendGraph( const uniqueGzipValues = [...gzipFormattedToValue.values()]; const uniqueRawValues = [...rawFormattedToValue.values()]; - const minGzipSize = Math.min(...uniqueGzipValues); - const maxGzipSize = Math.max(...uniqueGzipValues); + const minGzipSize = hasGzipData ? Math.min(...uniqueGzipValues) : 0; + const maxGzipSize = hasGzipData ? Math.max(...uniqueGzipValues) : 0; const minRawSize = Math.min(...uniqueRawValues); const maxRawSize = Math.max(...uniqueRawValues); @@ -149,23 +159,28 @@ export function renderTrendGraph( lines.push("─".repeat(60)); lines.push(""); - // Gzip size bars - lines.push(blue("Gzip Size:")); - for (const result of results) { - const sizeStr = formatBytes(result.gzipSize); - // Use representative value for this formatted string to ensure consistent bar length - const representativeValue = gzipFormattedToValue.get(sizeStr) as number; - const barLength = calcBarLength( - representativeValue, - minGzipSize, - maxGzipSize, - ); - const bar = "▇".repeat(barLength); - const padding = " ".repeat(maxVersionLen - result.version.length); - lines.push(` ${result.version}${padding} ${bar} ${sizeStr}`); - } + // Gzip size bars (only for browser platform) + if (hasGzipData) { + lines.push(blue("Gzip Size:")); + for (const result of results) { + if (result.gzipSize === null) { + continue; + } + const sizeStr = formatBytes(result.gzipSize); + // Use representative value for this formatted string to ensure consistent bar length + const representativeValue = gzipFormattedToValue.get(sizeStr) as number; + const barLength = calcBarLength( + representativeValue, + minGzipSize, + maxGzipSize, + ); + const bar = "▇".repeat(barLength); + const padding = " ".repeat(maxVersionLen - result.version.length); + lines.push(` ${result.version}${padding} ${bar} ${sizeStr}`); + } - lines.push(""); + lines.push(""); + } // Raw size bars lines.push(blue("Raw Size:")); @@ -190,15 +205,8 @@ export function renderTrendGraph( const newestResult = results[0]; if (results.length > 1) { - const gzipDiff = newestResult.gzipSize - oldestResult.gzipSize; - const gzipPercent = ((gzipDiff / oldestResult.gzipSize) * 100).toFixed(1); const rawDiff = newestResult.rawSize - oldestResult.rawSize; const rawPercent = ((rawDiff / oldestResult.rawSize) * 100).toFixed(1); - - const gzipTrend = - gzipDiff >= 0 - ? `+${formatBytes(gzipDiff)}` - : `-${formatBytes(Math.abs(gzipDiff))}`; const rawTrend = rawDiff >= 0 ? `+${formatBytes(rawDiff)}` @@ -208,9 +216,24 @@ export function renderTrendGraph( lines.push( `Change from ${oldestResult.version} to ${newestResult.version}:`, ); - lines.push( - ` ${blue("Gzip:")} ${gzipTrend} (${gzipDiff >= 0 ? "+" : ""}${gzipPercent}%)`, - ); + + // Gzip summary (only for browser platform) + if ( + hasGzipData && + newestResult.gzipSize !== null && + oldestResult.gzipSize !== null + ) { + const gzipDiff = newestResult.gzipSize - oldestResult.gzipSize; + const gzipPercent = ((gzipDiff / oldestResult.gzipSize) * 100).toFixed(1); + const gzipTrend = + gzipDiff >= 0 + ? `+${formatBytes(gzipDiff)}` + : `-${formatBytes(Math.abs(gzipDiff))}`; + lines.push( + ` ${blue("Gzip:")} ${gzipTrend} (${gzipDiff >= 0 ? "+" : ""}${gzipPercent}%)`, + ); + } + lines.push( ` ${blue("Raw:")} ${rawTrend} (${rawDiff >= 0 ? "+" : ""}${rawPercent}%)`, );