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.

refactor: validate packument

Mary 0c642f64 244a43a0

+148 -97
+1 -9
src/components/package-dependencies.tsx
··· 261 261 </Show> 262 262 </div> 263 263 264 - {/* description */} 265 - <Show when={props.pkg.description}> 266 - <p class="line-clamp-2 text-base-300 text-neutral-foreground-2">{props.pkg.description}</p> 267 - </Show> 268 - 269 264 {/* stats */} 270 265 <div class="flex flex-wrap gap-4 text-base-300"> 271 266 <span class="text-neutral-foreground-3"> ··· 312 307 let result = displayPackages(); 313 308 314 309 if (filterText) { 315 - result = result.filter( 316 - (pkg) => 317 - pkg.name.toLowerCase().includes(filterText) || pkg.description?.toLowerCase().includes(filterText), 318 - ); 310 + result = result.filter((pkg) => pkg.name.toLowerCase().includes(filterText)); 319 311 } else { 320 312 result = [...result]; 321 313 }
-4
src/npm/lib/hoist.ts
··· 22 22 tarball: pkg.tarball, 23 23 integrity: pkg.integrity, 24 24 unpackedSize: pkg.unpackedSize, 25 - description: pkg.description, 26 - license: pkg.license, 27 25 dependencyCount: pkg.dependencies.size, 28 26 nested: new Map(), 29 27 }); ··· 87 85 tarball: pkg.tarball, 88 86 integrity: pkg.integrity, 89 87 unpackedSize: pkg.unpackedSize, 90 - description: pkg.description, 91 - license: pkg.license, 92 88 dependencyCount: pkg.dependencies.size, 93 89 nested: new Map(), 94 90 };
-2
src/npm/lib/installed-packages.ts
··· 115 115 level, 116 116 dependents, 117 117 dependencies, 118 - description: pkg.description, 119 - license: pkg.license, 120 118 isPeer: isDirectPeerOfRoot || isOnlyReachableThroughPeers, 121 119 }); 122 120 }
+14 -8
src/npm/lib/registry.ts
··· 1 + import * as v from 'valibot'; 2 + 1 3 import { FetchError, InvalidSpecifierError, PackageNotFoundError } from './errors'; 2 - import type { Packument, Registry } from './types'; 4 + import { abbreviatedPackumentSchema, type AbbreviatedPackument, type Registry } from './types'; 3 5 4 6 const NPM_REGISTRY = 'https://registry.npmjs.org'; 5 7 const JSR_REGISTRY = 'https://npm.jsr.io'; ··· 8 10 * cache for packuments to avoid refetching during resolution. 9 11 * key format: "registry:name" (e.g., "npm:react" or "jsr:@luca/flag") 10 12 */ 11 - const packumentCache = new Map<string, Packument>(); 13 + const packumentCache = new Map<string, AbbreviatedPackument>(); 12 14 13 15 /** 14 16 * transforms a JSR package name to the npm-compatible format. ··· 45 47 } 46 48 47 49 /** 48 - * fetches the packument (full package metadata) from a registry. 49 - * uses the abbreviated format when possible for smaller payloads. 50 + * fetches the abbreviated packument from a registry. 51 + * the abbreviated format contains only installation-relevant metadata. 50 52 * 51 53 * @param name the package name (can be scoped like @scope/pkg) 52 54 * @param registry which registry to fetch from (defaults to 'npm') 53 - * @returns the packument with all versions 54 - * @throws if the package doesn't exist or network fails 55 + * @returns the abbreviated packument with all versions 56 + * @throws if the package doesn't exist, network fails, or response is invalid 55 57 */ 56 - export async function fetchPackument(name: string, registry: Registry = 'npm'): Promise<Packument> { 58 + export async function fetchPackument( 59 + name: string, 60 + registry: Registry = 'npm', 61 + ): Promise<AbbreviatedPackument> { 57 62 const cacheKey = `${registry}:${name}`; 58 63 const cached = packumentCache.get(cacheKey); 59 64 if (cached) { ··· 91 96 throw new FetchError(url, response.status, response.statusText); 92 97 } 93 98 94 - const packument = (await response.json()) as Packument; 99 + const json = await response.json(); 100 + const packument = v.parse(abbreviatedPackumentSchema, json); 95 101 packumentCache.set(cacheKey, packument); 96 102 return packument; 97 103 }
+6 -6
src/npm/lib/resolve.test.ts
··· 3 3 import { hoist, hoistedToPaths } from './hoist'; 4 4 import { reverseJsrName, transformJsrName } from './registry'; 5 5 import { parseSpecifier, pickVersion, resolve } from './resolve'; 6 - import type { PackageManifest } from './types'; 6 + import type { AbbreviatedManifest } from './types'; 7 7 8 8 describe('parseSpecifier', () => { 9 9 it('parses bare package name', () => { ··· 132 132 }); 133 133 134 134 describe('pickVersion', () => { 135 - const mockVersions: Record<string, PackageManifest> = { 135 + const mockVersions: Record<string, AbbreviatedManifest> = { 136 136 '1.0.0': { 137 137 name: 'test', 138 138 version: '1.0.0', 139 - dist: { tarball: 'https://example.com/test-1.0.0.tgz' }, 139 + dist: { tarball: 'https://example.com/test-1.0.0.tgz', shasum: 'abc123' }, 140 140 }, 141 141 '1.1.0': { 142 142 name: 'test', 143 143 version: '1.1.0', 144 - dist: { tarball: 'https://example.com/test-1.1.0.tgz' }, 144 + dist: { tarball: 'https://example.com/test-1.1.0.tgz', shasum: 'abc124' }, 145 145 }, 146 146 '2.0.0': { 147 147 name: 'test', 148 148 version: '2.0.0', 149 - dist: { tarball: 'https://example.com/test-2.0.0.tgz' }, 149 + dist: { tarball: 'https://example.com/test-2.0.0.tgz', shasum: 'abc125' }, 150 150 }, 151 151 '2.1.0-beta.1': { 152 152 name: 'test', 153 153 version: '2.1.0-beta.1', 154 - dist: { tarball: 'https://example.com/test-2.1.0-beta.1.tgz' }, 154 + dist: { tarball: 'https://example.com/test-2.1.0-beta.1.tgz', shasum: 'abc126' }, 155 155 }, 156 156 }; 157 157
+9 -5
src/npm/lib/resolve.ts
··· 4 4 5 5 import { InvalidSpecifierError, NoMatchingVersionError } from './errors'; 6 6 import { fetchPackument, reverseJsrName } from './registry'; 7 - import type { PackageManifest, PackageSpecifier, Registry, ResolvedPackage, ResolutionResult } from './types'; 7 + import type { 8 + AbbreviatedManifest, 9 + PackageSpecifier, 10 + Registry, 11 + ResolvedPackage, 12 + ResolutionResult, 13 + } from './types'; 8 14 9 15 /** 10 16 * parses a package specifier string into name, range, and registry. ··· 70 76 * @returns the best matching manifest, or null if none match 71 77 */ 72 78 export function pickVersion( 73 - versions: Record<string, PackageManifest>, 79 + versions: Record<string, AbbreviatedManifest>, 74 80 distTags: Record<string, string>, 75 81 range: string, 76 - ): PackageManifest | null { 82 + ): AbbreviatedManifest | null { 77 83 // check if range is a dist-tag 78 84 if (range in distTags) { 79 85 const taggedVersion = distTags[range]; ··· 176 182 tarball: manifest.dist.tarball, 177 183 integrity: manifest.dist.integrity, 178 184 unpackedSize: manifest.dist.unpackedSize, 179 - description: manifest.description, 180 - license: manifest.license, 181 185 dependencies: new Map(), 182 186 }; 183 187
+118 -61
src/npm/lib/types.ts
··· 1 + import * as v from 'valibot'; 2 + 3 + // #region package.json schema 4 + 1 5 /** 2 - * the parsed package.json of the main package. 3 - * includes fields relevant for export discovery and display. 6 + * package exports field - can be a string, array, object, or nested conditions. 7 + * @see https://nodejs.org/api/packages.html#exports 4 8 */ 5 - export interface PackageJson { 6 - name: string; 7 - version: string; 8 - description?: string; 9 - license?: string; 10 - main?: string; 11 - module?: string; 12 - browser?: string | Record<string, string | false>; 13 - types?: string; 14 - typings?: string; 15 - exports?: PackageExports; 16 - type?: 'module' | 'commonjs'; 17 - peerDependencies?: Record<string, string>; 18 - } 9 + const packageExportsSchema: v.GenericSchema<PackageExports> = v.union([ 10 + v.null(), 11 + v.string(), 12 + v.array(v.string()), 13 + v.record( 14 + v.string(), 15 + v.lazy(() => packageExportsSchema), 16 + ), 17 + ]); 18 + 19 + export type PackageExports = string | string[] | { [key: string]: PackageExports } | null; 19 20 20 21 /** 21 - * package exports field - can be a string, array, object, or nested conditions. 22 - * https://nodejs.org/api/packages.html#exports 22 + * base package.json schema with all standard fields. 23 + * other schemas pick from this to ensure consistency. 24 + * @see https://docs.npmjs.com/cli/v10/configuring-npm/package-json 23 25 */ 24 - export type PackageExports = string | string[] | { [key: string]: PackageExports } | null; 26 + export const packageJsonSchema = v.object({ 27 + name: v.string(), 28 + version: v.string(), 29 + description: v.optional(v.string()), 30 + keywords: v.optional(v.array(v.string())), 31 + homepage: v.optional(v.string()), 32 + license: v.optional(v.string()), 33 + main: v.optional(v.string()), 34 + module: v.optional(v.string()), 35 + browser: v.optional(v.union([v.string(), v.record(v.string(), v.union([v.string(), v.literal(false)]))])), 36 + types: v.optional(v.string()), 37 + typings: v.optional(v.string()), 38 + exports: v.optional(packageExportsSchema), 39 + type: v.optional(v.picklist(['module', 'commonjs'])), 40 + bin: v.optional(v.union([v.string(), v.record(v.string(), v.string())])), 41 + directories: v.optional(v.record(v.string(), v.string())), 42 + dependencies: v.optional(v.record(v.string(), v.string())), 43 + devDependencies: v.optional(v.record(v.string(), v.string())), 44 + peerDependencies: v.optional(v.record(v.string(), v.string())), 45 + peerDependenciesMeta: v.optional(v.record(v.string(), v.object({ optional: v.optional(v.boolean()) }))), 46 + bundleDependencies: v.optional(v.union([v.boolean(), v.array(v.string())])), 47 + optionalDependencies: v.optional(v.record(v.string(), v.string())), 48 + engines: v.optional(v.record(v.string(), v.string())), 49 + os: v.optional(v.array(v.string())), 50 + cpu: v.optional(v.array(v.string())), 51 + deprecated: v.optional(v.union([v.string(), v.boolean()])), 52 + sideEffects: v.optional(v.union([v.boolean(), v.array(v.string())])), 53 + }); 54 + 55 + export type PackageJson = v.InferOutput<typeof packageJsonSchema>; 56 + 57 + // #endregion 58 + 59 + // #region abbreviated packument schemas 60 + 61 + /** 62 + * distribution metadata for a package version. 63 + */ 64 + const distSchema = v.object({ 65 + tarball: v.string(), 66 + shasum: v.string(), 67 + integrity: v.optional(v.string()), 68 + fileCount: v.optional(v.number()), 69 + unpackedSize: v.optional(v.number()), 70 + signatures: v.optional( 71 + v.array( 72 + v.object({ 73 + keyid: v.string(), 74 + sig: v.string(), 75 + }), 76 + ), 77 + ), 78 + }); 25 79 26 80 /** 27 - * npm registry packument - the full metadata for a package including all versions. 28 - * fetched from registry.npmjs.org/{package-name} 81 + * abbreviated manifest for a specific version. 82 + * picks installation-relevant fields from package.json and adds registry metadata. 83 + * @see https://github.com/npm/registry/blob/main/docs/responses/package-metadata.md#abbreviated-metadata-format 29 84 */ 30 - export interface Packument { 31 - name: string; 32 - 'dist-tags': Record<string, string>; 33 - versions: Record<string, PackageManifest>; 34 - time?: Record<string, string>; 35 - } 85 + export const abbreviatedManifestSchema = v.object({ 86 + // pick installation-relevant fields from package.json 87 + ...v.pick(packageJsonSchema, [ 88 + 'name', 89 + 'version', 90 + 'deprecated', 91 + 'dependencies', 92 + 'devDependencies', 93 + 'optionalDependencies', 94 + 'bundleDependencies', 95 + 'peerDependencies', 96 + 'peerDependenciesMeta', 97 + 'bin', 98 + 'directories', 99 + 'engines', 100 + 'cpu', 101 + 'os', 102 + ]).entries, 103 + // registry-specific fields 104 + dist: distSchema, 105 + hasInstallScript: v.optional(v.boolean()), 106 + _hasShrinkwrap: v.optional(v.boolean()), 107 + }); 108 + 109 + export type AbbreviatedManifest = v.InferOutput<typeof abbreviatedManifestSchema>; 36 110 37 111 /** 38 - * package manifest for a specific version. 39 - * this is what you'd find in a package.json plus registry metadata. 112 + * abbreviated packument - minimal metadata for package resolution. 113 + * returned when requesting with Accept: application/vnd.npm.install-v1+json 114 + * @see https://github.com/npm/registry/blob/main/docs/responses/package-metadata.md#abbreviated-metadata-format 40 115 */ 41 - export interface PackageManifest { 42 - name: string; 43 - version: string; 44 - description?: string; 45 - license?: string; 46 - main?: string; 47 - module?: string; 48 - exports?: PackageExports; 49 - type?: 'module' | 'commonjs'; 50 - dependencies?: Record<string, string>; 51 - devDependencies?: Record<string, string>; 52 - peerDependencies?: Record<string, string>; 53 - peerDependenciesMeta?: Record<string, { optional?: boolean }>; 54 - optionalDependencies?: Record<string, string>; 55 - dist: { 56 - tarball: string; 57 - integrity?: string; 58 - shasum?: string; 59 - /** total unpacked size in bytes */ 60 - unpackedSize?: number; 61 - /** number of files in the tarball */ 62 - fileCount?: number; 63 - }; 64 - } 116 + export const abbreviatedPackumentSchema = v.object({ 117 + name: v.string(), 118 + // optional because some registries (e.g., JSR's npm mirror) may not include it 119 + modified: v.optional(v.string()), 120 + 'dist-tags': v.pipe( 121 + v.record(v.string(), v.string()), 122 + v.check((tags) => 'latest' in tags, 'dist-tags must include "latest"'), 123 + ), 124 + versions: v.record(v.string(), abbreviatedManifestSchema), 125 + }); 126 + 127 + export type AbbreviatedPackument = v.InferOutput<typeof abbreviatedPackumentSchema>; 128 + 129 + // #endregion 65 130 66 131 /** 67 132 * a resolved package with its dependencies. ··· 76 141 integrity?: string; 77 142 /** unpacked size in bytes (from registry) */ 78 143 unpackedSize?: number; 79 - /** package description */ 80 - description?: string; 81 - /** license identifier */ 82 - license?: string; 83 144 /** resolved dependencies (name -> ResolvedPackage) */ 84 145 dependencies: Map<string, ResolvedPackage>; 85 146 } ··· 122 183 integrity?: string; 123 184 /** unpacked size in bytes (from registry) */ 124 185 unpackedSize?: number; 125 - /** package description */ 126 - description?: string; 127 - /** license identifier */ 128 - license?: string; 129 186 /** number of direct dependencies */ 130 187 dependencyCount: number; 131 188 /** nested node_modules for this package (when hoisting fails) */
-2
src/npm/types.ts
··· 64 64 level: v.number(), 65 65 dependents: v.array(packageRefSchema), 66 66 dependencies: v.array(packageRefSchema), 67 - description: v.optional(v.string()), 68 - license: v.optional(v.string()), 69 67 isPeer: v.boolean(), 70 68 }); 71 69