Find the cost of adding an npm package to your app's bundle size teardown.kelinci.dev
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: option to exclude direct peer dependencies

Mary 4fd3340c 8199d248

+245 -105
+13 -11
src/components/package-bundle.tsx
··· 16 16 17 17 // #region helpers 18 18 19 - function serializeCacheKey(subpath: string, exports: string[] | null): string { 20 - if (exports === null) { 21 - return subpath; 22 - } 23 - return `${subpath}\0${exports.join('\0')}`; 19 + function serializeCacheKey(subpath: string, exports: string[] | null, excludePeers: boolean): string { 20 + const base = exports === null ? subpath : `${subpath}\0${exports.join('\0')}`; 21 + return excludePeers ? `${base}\0peers` : base; 24 22 } 25 23 26 24 function arraysEqual(a: string[], b: string[]): boolean { ··· 43 41 packageName: string; 44 42 subpaths: DiscoveredSubpaths; 45 43 worker: BundlerWorker; 44 + excludePeers: boolean; 45 + peerDependencies: string[]; 46 46 } 47 47 48 48 const PackageBundle = (props: PackageBundleProps) => { 49 49 const packageName = props.packageName; 50 50 const subpaths = props.subpaths; 51 51 const worker = props.worker; 52 + const peerDependencies = props.peerDependencies; 52 53 53 54 /** formats a subpath for display, replacing `.` and `./` with the package name */ 54 55 const formatSubpath = (subpath: string) => { ··· 62 63 return subpath; 63 64 }; 64 65 65 - const bundleCache = new LRUCache<string, BundleResult>(16); 66 + const bundleCache = new LRUCache<string, BundleResult>(32); 66 67 67 68 const [subpath, setSubpath] = createSignal(subpaths.defaultSubpath!); 68 69 ··· 70 71 const [initialBundle, { refetch: refetchInitial }] = createQuery( 71 72 subpath, 72 73 async (subpath) => { 73 - const cacheKey = serializeCacheKey(subpath, null); 74 + const cacheKey = serializeCacheKey(subpath, null, false); 74 75 const cached = bundleCache.peek(cacheKey); 75 76 if (cached) { 76 77 return cached; ··· 106 107 // if selection equals all exports, pass null to reuse LRU cache 107 108 const exportsParam = arraysEqual(exports, $initialBundle.exports) ? null : exports; 108 109 109 - return { subpath: $subpath, exports: exportsParam }; 110 + return { subpath: $subpath, exports: exportsParam, excludePeers: props.excludePeers }; 110 111 }, 111 - async ({ subpath, exports }) => { 112 - const cacheKey = serializeCacheKey(subpath, exports); 112 + async ({ subpath, exports, excludePeers }) => { 113 + const cacheKey = serializeCacheKey(subpath, exports, excludePeers); 113 114 const cached = bundleCache.get(cacheKey); 114 115 if (cached) { 115 116 return cached; 116 117 } 117 118 118 - const res = await worker.bundle(subpath, exports); 119 + const options = excludePeers ? { rolldown: { external: peerDependencies } } : undefined; 120 + const res = await worker.bundle(subpath, exports, options); 119 121 bundleCache.put(cacheKey, res); 120 122 return res; 121 123 },
+15 -5
src/components/package-dependencies.tsx
··· 166 166 interface PackageDependenciesProps { 167 167 packages: InstalledPackage[]; 168 168 installSize: number; 169 + excludePeers: boolean; 169 170 } 170 171 171 172 const PackageDependencies = (props: PackageDependenciesProps) => { 172 173 const [filter, setFilter] = createSignal(''); 173 174 const [sortBy, setSortBy] = createSignal<SortOption>('level'); 175 + 176 + // filter out peer packages when excludePeers is true 177 + const displayPackages = createMemo(() => { 178 + return props.excludePeers ? props.packages.filter((p) => !p.isPeer) : props.packages; 179 + }); 180 + 181 + const displayInstallSize = createMemo(() => { 182 + return displayPackages().reduce((sum, pkg) => sum + pkg.size, 0); 183 + }); 174 184 175 185 const filteredAndSorted = createMemo(() => { 176 186 const filterText = filter().toLowerCase(); 177 187 const sortConfig = SORT_OPTIONS[sortBy()]; 178 188 179 - let result = props.packages; 189 + let result = displayPackages(); 180 190 181 191 if (filterText) { 182 192 result = result.filter( ··· 195 205 <h3 class="text-base-400 font-semibold text-neutral-foreground-1">Install size</h3> 196 206 <div class="text-base-300 text-neutral-foreground-2"> 197 207 <span class="text-base-400 font-semibold text-neutral-foreground-1"> 198 - {formatBytes(props.installSize)} 208 + {formatBytes(displayInstallSize())} 199 209 </span> 200 - <span class="text-neutral-foreground-3"> across {props.packages.length} packages</span> 210 + <span class="text-neutral-foreground-3"> across {displayPackages().length} packages</span> 201 211 </div> 202 212 </div> 203 213 204 214 {/* size breakdown bar */} 205 - <SizeBreakdownBar packages={props.packages} installSize={props.installSize} /> 215 + <SizeBreakdownBar packages={displayPackages()} installSize={displayInstallSize()} /> 206 216 207 217 {/* filter and sort controls */} 208 218 <div class="flex flex-col gap-3 sm:flex-row sm:items-center"> ··· 234 244 <div class="-mx-3 flex flex-col"> 235 245 <For each={filteredAndSorted()}> 236 246 {(pkg) => { 237 - const percent = (pkg.size / props.installSize) * 100; 247 + const percent = (pkg.size / displayInstallSize()) * 100; 238 248 return <PackageCard pkg={pkg} percent={percent} />; 239 249 }} 240 250 </For>
+22
src/components/package-result.tsx
··· 1 + import { createSignal, Show } from 'solid-js'; 2 + 1 3 import type { PackageSession } from '../npm/worker-client'; 2 4 3 5 import PackageBundle from './package-bundle'; ··· 11 13 12 14 const PackageResult = (props: PackageResultProps) => { 13 15 const result = props.result; 16 + 17 + const [excludePeers, setExcludePeers] = createSignal(false); 18 + 19 + const hasPeerDeps = result.peerDependencies.length > 0; 14 20 15 21 return ( 16 22 <div class="flex flex-col gap-8"> ··· 23 29 <span class="my-1.25 text-base-400 text-neutral-foreground-3">{result.version}</span> 24 30 </div> 25 31 32 + {/* peer deps toggle */} 33 + <Show when={hasPeerDeps}> 34 + <label class="flex items-center gap-2 text-base-300 text-neutral-foreground-2"> 35 + <input 36 + type="checkbox" 37 + checked={excludePeers()} 38 + onChange={(e) => setExcludePeers(e.currentTarget.checked)} 39 + class="accent-brand-background-1 size-4" 40 + /> 41 + <span>Exclude peer dependencies</span> 42 + </label> 43 + </Show> 44 + 26 45 {/* bundle size section */} 27 46 {result.subpaths.defaultSubpath !== null && ( 28 47 <> ··· 30 49 packageName={/* @once */ result.name} 31 50 subpaths={/* @once */ result.subpaths} 32 51 worker={/* @once */ result.worker} 52 + excludePeers={excludePeers()} 53 + peerDependencies={/* @once */ result.peerDependencies} 33 54 /> 34 55 <hr class="border-neutral-stroke-3" /> 35 56 </> ··· 39 60 <PackageDependencies 40 61 packages={/* @once */ result.packages} 41 62 installSize={/* @once */ result.installSize} 63 + excludePeers={excludePeers()} 42 64 /> 43 65 </div> 44 66 );
+92
src/npm/installed-packages.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { buildInstalledPackages } from './installed-packages'; 4 + import { resolve } from './resolve'; 5 + 6 + describe('buildInstalledPackages', () => { 7 + it('builds packages from a simple dependency tree', async () => { 8 + const result = await resolve(['is-odd@3.0.1']); 9 + const packages = buildInstalledPackages(result.roots[0], new Set()); 10 + 11 + // should have is-odd and is-number 12 + const names = packages.map((p) => p.name); 13 + expect(names).toContain('is-odd'); 14 + expect(names).toContain('is-number'); 15 + 16 + // is-odd should be level 0, is-number should be level 1 17 + const isOdd = packages.find((p) => p.name === 'is-odd')!; 18 + const isNumber = packages.find((p) => p.name === 'is-number')!; 19 + expect(isOdd.level).toBe(0); 20 + expect(isNumber.level).toBe(1); 21 + 22 + // none should be marked as peer (no peer deps) 23 + expect(packages.every((p) => !p.isPeer)).toBe(true); 24 + }); 25 + 26 + it('correctly sets installedBy count', async () => { 27 + const result = await resolve(['is-odd@3.0.1']); 28 + const packages = buildInstalledPackages(result.roots[0], new Set()); 29 + 30 + // is-odd is the root, installedBy should be 0 31 + const isOdd = packages.find((p) => p.name === 'is-odd')!; 32 + expect(isOdd.installedBy).toBe(0); 33 + 34 + // is-number is depended on by is-odd 35 + const isNumber = packages.find((p) => p.name === 'is-number')!; 36 + expect(isNumber.installedBy).toBe(1); 37 + }); 38 + 39 + it('correctly sets dependencyCount', async () => { 40 + const result = await resolve(['is-odd@3.0.1']); 41 + const packages = buildInstalledPackages(result.roots[0], new Set()); 42 + 43 + // is-odd has 1 dependency (is-number) 44 + const isOdd = packages.find((p) => p.name === 'is-odd')!; 45 + expect(isOdd.dependencyCount).toBe(1); 46 + }); 47 + 48 + it('marks peer dependencies correctly', async () => { 49 + // use-sync-external-store has react as a peer dependency 50 + const result = await resolve(['use-sync-external-store@1.2.0']); 51 + const peerDepNames = new Set(['react']); 52 + const packages = buildInstalledPackages(result.roots[0], peerDepNames); 53 + 54 + // react and its deps should be marked as peer 55 + const react = packages.find((p) => p.name === 'react'); 56 + expect(react).toBeDefined(); 57 + expect(react!.isPeer).toBe(true); 58 + 59 + // use-sync-external-store should not be marked as peer 60 + const main = packages.find((p) => p.name === 'use-sync-external-store')!; 61 + expect(main.isPeer).toBe(false); 62 + }); 63 + 64 + it('marks transitive peer deps correctly', async () => { 65 + // use-sync-external-store@1.2.0 has react as peer 66 + // react has loose-envify as a regular dep 67 + // loose-envify should be marked as peer (only reachable through react) 68 + const result = await resolve(['use-sync-external-store@1.2.0']); 69 + const peerDepNames = new Set(['react']); 70 + const packages = buildInstalledPackages(result.roots[0], peerDepNames); 71 + 72 + const looseEnvify = packages.find((p) => p.name === 'loose-envify'); 73 + // loose-envify is a dep of react, which is peer-only 74 + if (looseEnvify) { 75 + expect(looseEnvify.isPeer).toBe(true); 76 + } 77 + }); 78 + 79 + it('does not mark shared deps as peer when reachable both ways', async () => { 80 + // if a package is reachable through both regular and peer deps, 81 + // it should NOT be marked as peer 82 + const result = await resolve(['is-odd@3.0.1']); 83 + 84 + // pretend is-number is also a peer dep (but it's already a regular dep) 85 + const peerDepNames = new Set(['is-number']); 86 + const packages = buildInstalledPackages(result.roots[0], peerDepNames); 87 + 88 + // is-number should still be marked as peer because it's only through peer edge 89 + const isNumber = packages.find((p) => p.name === 'is-number')!; 90 + expect(isNumber.isPeer).toBe(true); 91 + }); 92 + });
+92
src/npm/installed-packages.ts
··· 1 + import type { ResolvedPackage } from './types'; 2 + import type { InstalledPackage } from './worker-protocol'; 3 + 4 + /** 5 + * builds the installed packages list from the resolved dependency tree. 6 + * also identifies which packages are only reachable through peer dependencies. 7 + * 8 + * @param root the root resolved package 9 + * @param peerDepNames names of the root package's peer dependencies 10 + * @returns array of installed packages with peer status 11 + */ 12 + export function buildInstalledPackages(root: ResolvedPackage, peerDepNames: Set<string>): InstalledPackage[] { 13 + // first pass: collect all unique packages and compute levels + installedBy 14 + const packageMap = new Map< 15 + string, 16 + { 17 + pkg: ResolvedPackage; 18 + level: number; 19 + installedBy: number; 20 + } 21 + >(); 22 + 23 + // track which packages are reachable without going through peer deps 24 + const reachableWithoutPeers = new Set<string>(); 25 + 26 + { 27 + const visited = new Set<string>(); 28 + 29 + function walk(pkg: ResolvedPackage, level: number, inPeerSubtree: boolean): void { 30 + const key = `${pkg.name}@${pkg.version}`; 31 + 32 + // update level to shortest path 33 + const existing = packageMap.get(key); 34 + if (existing) { 35 + if (level < existing.level) { 36 + existing.level = level; 37 + } 38 + } else { 39 + packageMap.set(key, { pkg, level, installedBy: 0 }); 40 + } 41 + 42 + // track if reachable without peers 43 + if (!inPeerSubtree) { 44 + reachableWithoutPeers.add(key); 45 + } 46 + 47 + // avoid infinite loops from cycles 48 + if (visited.has(key)) { 49 + return; 50 + } 51 + visited.add(key); 52 + 53 + // count installedBy for each dependency 54 + for (const [depName, dep] of pkg.dependencies) { 55 + const depKey = `${dep.name}@${dep.version}`; 56 + const depEntry = packageMap.get(depKey); 57 + if (depEntry) { 58 + depEntry.installedBy++; 59 + } else { 60 + packageMap.set(depKey, { pkg: dep, level: level + 1, installedBy: 1 }); 61 + } 62 + 63 + // check if this edge goes through a root peer dep 64 + const isPeerEdge = pkg === root && peerDepNames.has(depName); 65 + walk(dep, level + 1, inPeerSubtree || isPeerEdge); 66 + } 67 + 68 + visited.delete(key); 69 + } 70 + 71 + walk(root, 0, false); 72 + } 73 + 74 + // build final array 75 + const packages: InstalledPackage[] = []; 76 + for (const [key, { pkg, level, installedBy }] of packageMap) { 77 + packages.push({ 78 + name: pkg.name, 79 + version: pkg.version, 80 + size: pkg.unpackedSize ?? 0, 81 + path: `node_modules/${pkg.name}`, 82 + level, 83 + installedBy, 84 + dependencyCount: pkg.dependencies.size, 85 + description: pkg.description, 86 + license: pkg.license, 87 + isPeer: !reachableWithoutPeers.has(key), 88 + }); 89 + } 90 + 91 + return packages; 92 + }
+1
src/npm/types.ts
··· 14 14 typings?: string; 15 15 exports?: PackageExports; 16 16 type?: 'module' | 'commonjs'; 17 + peerDependencies?: Record<string, string>; 17 18 } 18 19 19 20 /**
+2
src/npm/worker-protocol.ts
··· 50 50 dependencyCount: v.number(), 51 51 description: v.optional(v.string()), 52 52 license: v.optional(v.string()), 53 + isPeer: v.boolean(), 53 54 }); 54 55 55 56 const initResultSchema = v.object({ ··· 58 59 subpaths: discoveredSubpathsSchema, 59 60 installSize: v.number(), 60 61 packages: v.array(installedPackageSchema), 62 + peerDependencies: v.array(v.string()), 61 63 }); 62 64 63 65 const bundleChunkSchema = v.object({
+8 -89
src/npm/worker.ts
··· 7 7 import { progress } from './events'; 8 8 import { fetchPackagesToVolume } from './fetch'; 9 9 import { hoist } from './hoist'; 10 + import { buildInstalledPackages } from './installed-packages'; 10 11 import { resolve } from './resolve'; 11 12 import { discoverSubpaths } from './subpaths'; 12 - import type { HoistedNode, HoistedResult, PackageJson, ResolvedPackage } from './types'; 13 + import type { PackageJson } from './types'; 13 14 import { 14 15 workerRequestSchema, 15 16 type InitOptions, 16 17 type InitResult, 17 - type InstalledPackage, 18 18 type WorkerResponse, 19 19 } from './worker-protocol'; 20 20 ··· 25 25 self.postMessage(msg satisfies WorkerResponse); 26 26 }); 27 27 28 - // #region helpers 29 - 30 - function computePackageLevels(roots: ResolvedPackage[]): Map<string, number> { 31 - const levels = new Map<string, number>(); 32 - const visited = new Set<string>(); 33 - 34 - function walk(pkg: ResolvedPackage, level: number): void { 35 - const key = `${pkg.name}@${pkg.version}`; 36 - 37 - const existingLevel = levels.get(key); 38 - if (existingLevel === undefined || level < existingLevel) { 39 - levels.set(key, level); 40 - } 41 - 42 - if (visited.has(key)) { 43 - return; 44 - } 45 - visited.add(key); 46 - 47 - for (const dep of pkg.dependencies.values()) { 48 - walk(dep, level + 1); 49 - } 50 - 51 - visited.delete(key); 52 - } 53 - 54 - for (const root of roots) { 55 - walk(root, 0); 56 - } 57 - 58 - return levels; 59 - } 60 - 61 - function buildInstalledPackages( 62 - hoisted: HoistedResult, 63 - packageLevels: Map<string, number>, 64 - ): InstalledPackage[] { 65 - const packages: InstalledPackage[] = []; 66 - const installedByCount = new Map<string, number>(); 67 - 68 - function collectPackages(nodes: Map<string, HoistedNode>, basePath: string): void { 69 - for (const node of nodes.values()) { 70 - const path = `${basePath}/${node.name}`; 71 - const key = `${node.name}@${node.version}`; 72 - 73 - packages.push({ 74 - name: node.name, 75 - version: node.version, 76 - size: node.unpackedSize ?? 0, 77 - path, 78 - level: packageLevels.get(key) ?? 0, 79 - installedBy: 0, 80 - dependencyCount: node.dependencyCount, 81 - description: node.description, 82 - license: node.license, 83 - }); 84 - 85 - for (const nested of node.nested.values()) { 86 - const nestedKey = `${nested.name}@${nested.version}`; 87 - installedByCount.set(nestedKey, (installedByCount.get(nestedKey) ?? 0) + 1); 88 - } 89 - 90 - if (node.nested.size > 0) { 91 - collectPackages(node.nested, `${path}/node_modules`); 92 - } 93 - } 94 - } 95 - 96 - for (const node of hoisted.root.values()) { 97 - const key = `${node.name}@${node.version}`; 98 - installedByCount.set(key, (installedByCount.get(key) ?? 0) + 1); 99 - } 100 - 101 - collectPackages(hoisted.root, 'node_modules'); 102 - 103 - for (const pkg of packages) { 104 - const key = `${pkg.name}@${pkg.version}`; 105 - pkg.installedBy = installedByCount.get(key) ?? 0; 106 - } 107 - 108 - return packages; 109 - } 110 - 111 - // #endregion 112 - 113 28 // #region state 114 29 115 30 let packageName: string | null = null; ··· 151 66 152 67 const subpaths = discoverSubpaths(manifest, volume); 153 68 154 - const packageLevels = computePackageLevels(resolution.roots); 155 - const packages = buildInstalledPackages(hoisted, packageLevels); 69 + // get peer dependency names from manifest 70 + const peerDependencies = Object.keys(manifest.peerDependencies ?? {}); 71 + const peerDepNames = new Set(peerDependencies); 72 + 73 + const packages = buildInstalledPackages(mainPackage, peerDepNames); 156 74 const installSize = packages.reduce((sum, pkg) => sum + pkg.size, 0); 157 75 158 76 initResult = { ··· 161 79 subpaths, 162 80 installSize, 163 81 packages, 82 + peerDependencies, 164 83 }; 165 84 166 85 self.postMessage({ id, type: 'init', result: initResult } satisfies WorkerResponse);