Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/bundlecheck/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- Custom npm registry support (for private registries)
- Fast bundling using esbuild (with pnpm support)

## Installation
Expand Down Expand Up @@ -101,6 +102,7 @@ bundlecheck lodash "debounce,throttle"
| `--gzipLevel <n>` | `-g <n>` | Gzip compression level (1-9, default: 5) |
| `--external <pkgs>` | `-e <pkgs>` | Comma-separated additional packages to mark as external |
| `--noExternal` | `-n` | Do not mark any packages as external |
| `--registry <url>` | `-r <url>` | Custom npm registry URL (default: registry.npmjs.org) |

### Examples

Expand Down Expand Up @@ -132,6 +134,9 @@ bundlecheck lodash --trend

# Show bundle size trend for 3 versions
bundlecheck lodash --trend 3

# Use a custom npm registry
bundlecheck @myorg/private-pkg --registry https://npm.mycompany.com
```

## How It Works
Expand All @@ -149,6 +154,21 @@ By default, `react` and `react-dom` are marked as external (not included in the

To include React/React-DOM in the bundle size calculation, use the `--no-external` flag.

## Custom Registry

Use the `--registry` flag to check packages from private or alternative npm registries:

```bash
# Private corporate registry
bundlecheck @myorg/myorg-ui-library --registry https://npm.mycompany.com

# Verdaccio local registry
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).

## License

MIT - see [LICENSE](./LICENSE) for details.
44 changes: 44 additions & 0 deletions packages/bundlecheck/src/__tests__/bundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,50 @@ describe("checkBundleSize", () => {
expect(result.gzipLevel).toBe(5);
});

it("should pass valid registry URL to install command", async () => {
await checkBundleSize({
packageName: "test-package",
registry: "https://registry.example.com",
});

// Verify execSync was called with the registry flag
const execCalls = vi.mocked(execSync).mock.calls;
const installCall = execCalls.find(
(call) => typeof call[0] === "string" && call[0].includes("--registry"),
);
expect(installCall).toBeDefined();
expect(installCall?.[0]).toContain(
'--registry "https://registry.example.com/"',
);
});

it("should reject invalid registry URL", async () => {
await expect(
checkBundleSize({
packageName: "test-package",
registry: "not-a-valid-url",
}),
).rejects.toThrow("Invalid registry URL");
});

it("should reject non-http/https registry URL", async () => {
await expect(
checkBundleSize({
packageName: "test-package",
registry: "ftp://registry.example.com",
}),
).rejects.toThrow("Invalid registry URL protocol");
});

it("should reject registry URL with command injection attempt", async () => {
await expect(
checkBundleSize({
packageName: "test-package",
registry: "https://evil.com; rm -rf /",
}),
).rejects.toThrow("Invalid registry URL");
});

it("should handle package with subpath", async () => {
const result = await checkBundleSize({
packageName: "@scope/package/subpath",
Expand Down
60 changes: 60 additions & 0 deletions packages/bundlecheck/src/__tests__/versions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,66 @@ describe("fetchPackageVersions", () => {

expect(result.tags).toEqual({});
});

it("should use custom registry URL when provided", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
versions: { "1.0.0": {} },
"dist-tags": { latest: "1.0.0" },
}),
});

await fetchPackageVersions({
packageName: "test-package",
registry: "https://custom.registry.com",
});

expect(mockFetch).toHaveBeenCalledWith(
"https://custom.registry.com/test-package",
);
});

it("should strip trailing slash from custom registry URL", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
versions: { "1.0.0": {} },
"dist-tags": { latest: "1.0.0" },
}),
});

await fetchPackageVersions({
packageName: "test-package",
registry: "https://custom.registry.com/",
});

expect(mockFetch).toHaveBeenCalledWith(
"https://custom.registry.com/test-package",
);
});

it("should use options object format", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
versions: { "1.0.0": {} },
"dist-tags": { latest: "1.0.0" },
}),
});

const result = await fetchPackageVersions({
packageName: "@scope/package",
});

expect(mockFetch).toHaveBeenCalledWith(
"https://registry.npmjs.org/@scope/package",
);
expect(result.versions).toEqual(["1.0.0"]);
});
});

describe("promptForVersion", () => {
Expand Down
12 changes: 10 additions & 2 deletions packages/bundlecheck/src/bundlecheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ async function main() {

log.info(`\nFetching available versions for ${name}...`);

const { versions } = await fetchPackageVersions(packageName);
const { versions } = await fetchPackageVersions({
packageName,
registry: flags?.registry,
});

if (versions.length === 0) {
log.error("No versions found for this package");
Expand All @@ -96,6 +99,7 @@ async function main() {
noExternal: flags?.noExternal,
gzipLevel: flags?.gzipLevel,
boring: flags?.boring,
registry: flags?.registry,
});

if (results.length === 0) {
Expand Down Expand Up @@ -128,7 +132,10 @@ async function main() {
const { name, subpath } = parsePackageSpecifier(packageName);
log.info(`\nFetching available versions for ${name}...`);

const { versions, tags } = await fetchPackageVersions(packageName);
const { versions, tags } = await fetchPackageVersions({
packageName,
registry: flags?.registry,
});

if (versions.length === 0) {
log.error("No versions found for this package");
Expand Down Expand Up @@ -162,6 +169,7 @@ async function main() {
additionalExternals,
noExternal: flags?.noExternal,
gzipLevel: flags?.gzipLevel,
registry: flags?.registry,
});

const blue = kleur.blue;
Expand Down
47 changes: 43 additions & 4 deletions packages/bundlecheck/src/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export type BundleOptions = {
additionalExternals?: string[];
noExternal?: boolean;
gzipLevel?: number;
registry?: string;
};

export type BundleResult = {
Expand Down Expand Up @@ -141,18 +142,55 @@ function isPnpmAvailable(): boolean {
// Cache the result of pnpm availability check
let usePnpm: boolean | null = null;

/**
* Validate and sanitize a registry URL to prevent command injection
* @param registry - The registry URL to validate
* @returns The sanitized URL or undefined if invalid
* @throws Error if the URL is invalid or contains potentially malicious characters
*/
function validateRegistryUrl(registry: string): string {
// Parse as URL to validate format
let url: URL;
try {
url = new URL(registry);
} catch {
throw new Error(
`Invalid registry URL: ${registry}. Must be a valid URL (e.g., https://registry.example.com)`,
);
}

// Only allow http and https protocols
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(
`Invalid registry URL protocol: ${url.protocol}. Only http: and https: are allowed`,
);
}

// Return the sanitized URL (URL constructor normalizes it)
return url.toString();
}

/**
* Get the install command (pnpm preferred, npm fallback)
* @param registry - Optional custom npm registry URL
*/
function getInstallCommand(): string {
function getInstallCommand(registry?: string): string {
if (usePnpm === null) {
usePnpm = isPnpmAvailable();
}

let registryArg = "";
if (registry) {
// Validate and sanitize the registry URL to prevent command injection
const sanitizedRegistry = validateRegistryUrl(registry);
// Quote the URL to handle any special characters safely
registryArg = ` --registry "${sanitizedRegistry}"`;
}

if (usePnpm) {
return "pnpm install --ignore-scripts --no-frozen-lockfile";
return `pnpm install --ignore-scripts --no-frozen-lockfile${registryArg}`;
Comment thread Fixed
}
return "npm install --legacy-peer-deps --ignore-scripts";
return `npm install --legacy-peer-deps --ignore-scripts${registryArg}`;
Comment thread Fixed
}

export type EntryContentOptions = {
Expand Down Expand Up @@ -446,6 +484,7 @@ export async function checkBundleSize(
additionalExternals,
noExternal,
gzipLevel = 5,
registry,
} = options;

// Parse the package specifier to extract name, version, and subpath
Expand Down Expand Up @@ -479,7 +518,7 @@ export async function checkBundleSize(
);

// Install the main package (try pnpm first, fallback to npm)
const installCmd = getInstallCommand();
const installCmd = getInstallCommand(registry);
execSync(installCmd, {
cwd: tmpDir,
stdio: "pipe",
Expand Down
3 changes: 3 additions & 0 deletions packages/bundlecheck/src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ export const defaultFlags = {
external: "",
noExternal: false,
versions: false,
registry: "",
};

export const TREND_VERSION_COUNT = 5;

export const DEFAULT_EXTERNALS = ["react", "react-dom"];

export const DEFAULT_REGISTRY = "https://registry.npmjs.org";
12 changes: 12 additions & 0 deletions packages/bundlecheck/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type Flags = {
gzipLevel?: number;
external?: string;
noExternal?: boolean;
registry?: string;
};

export type Parameters = {
Expand Down Expand Up @@ -70,6 +71,10 @@ export const config: Configuration = parser({
command: "bundlecheck lodash --trend 3",
comment: "## Show bundle size trend for 3 versions",
},
{
command: "bundlecheck lodash --registry https://registry.example.com",
comment: "## Use a custom npm registry",
},
],
flags: {
gzipLevel: {
Expand Down Expand Up @@ -118,6 +123,13 @@ export const config: Configuration = parser({
description: "Show bundle size trend for N recent versions (default: 5)",
type: "string",
},
registry: {
shortFlag: "r",
default: defaultFlags.registry,
description:
"Custom npm registry URL (default: https://registry.npmjs.org)",
type: "string",
},
},
parameters: {
package: {
Expand Down
3 changes: 3 additions & 0 deletions packages/bundlecheck/src/trend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type TrendOptions = {
noExternal?: boolean;
gzipLevel?: number;
boring?: boolean;
registry?: string;
};

/**
Expand Down Expand Up @@ -48,6 +49,7 @@ export async function analyzeTrend(
noExternal,
gzipLevel,
boring,
registry,
} = options;

const log = new Logger({ boring });
Expand All @@ -64,6 +66,7 @@ export async function analyzeTrend(
additionalExternals,
noExternal,
gzipLevel,
registry,
});

results.push({
Expand Down
20 changes: 18 additions & 2 deletions packages/bundlecheck/src/versions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import select from "@inquirer/select";
import { rsort } from "semver";
import { parsePackageSpecifier } from "./bundler.js";
import { DEFAULT_REGISTRY } from "./defaults.js";

export type NpmPackageInfo = {
versions: string[];
Expand All @@ -15,13 +16,28 @@ type NpmRegistryResponse = {
"dist-tags"?: Record<string, string>;
};

export type FetchVersionsOptions = {
packageName: string;
registry?: string;
};

export async function fetchPackageVersions(
packageName: string,
packageNameOrOptions: string | FetchVersionsOptions,
): Promise<NpmPackageInfo> {
// Support both string (legacy) and options object
const { packageName, registry } =
typeof packageNameOrOptions === "string"
? { packageName: packageNameOrOptions, registry: undefined }
: packageNameOrOptions;

// Parse the package specifier to get just the name (without version)
const { name } = parsePackageSpecifier(packageName);

const url = `https://registry.npmjs.org/${name}`;
// Use custom registry or default
const registryUrl = registry || DEFAULT_REGISTRY;
// Ensure no trailing slash
const baseUrl = registryUrl.replace(/\/$/, "");
const url = `${baseUrl}/${name}`;
const response = await fetch(url);

if (!response.ok) {
Expand Down