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: version selector

Mary 915fa769 4079fc67

+245 -96
+43 -56
src/app.tsx
··· 1 1 import { sample, sampleOne } from '@mary/array-fns'; 2 - import { createEffect, createMemo, createSignal, Match, onCleanup, Switch } from 'solid-js'; 2 + import { createMemo, Match, Switch } from 'solid-js'; 3 3 import * as v from 'valibot'; 4 4 5 5 import PackageResult from './components/package-result'; ··· 13 13 LucideScissorsLineDashed, 14 14 } from './icons/lucide'; 15 15 import { TangledDolly } from './icons/tangled'; 16 - import { PACKAGE_SPECIFIER_RE } from './lib/package-name'; 16 + import { 17 + formatPackageSpecifier, 18 + PACKAGE_SPECIFIER_RE, 19 + parsePackageSpecifier, 20 + type Registry, 21 + } from './lib/package-name'; 17 22 import { createQuery } from './lib/query'; 18 23 import { createDerivedSignal } from './lib/signals'; 19 24 import { useSearchParams } from './lib/use-search-params'; 20 - import { progress } from './npm/events'; 21 - import type { ProgressMessage } from './npm/types'; 22 - import { initPackage } from './npm/worker-client'; 25 + import { fetchPackageManifest } from './npm/packument'; 23 26 import Button from './primitives/button'; 24 27 import Tooltip from './primitives/tooltip'; 25 28 import { RECOMMENDATIONS } from './recommendations'; ··· 34 37 q: v.pipe(v.string(), v.regex(PACKAGE_SPECIFIER_RE)), 35 38 }); 36 39 37 - const packageName = createMemo(() => params().q); 40 + const parsed = createMemo(() => { 41 + const q = params().q; 42 + return q ? parsePackageSpecifier(q) : null; 43 + }); 38 44 39 - const [query, setQuery] = createDerivedSignal(() => packageName() ?? ''); 45 + const identity = createMemo<{ registry: Registry; name: string } | undefined>( 46 + () => { 47 + const p = parsed(); 48 + return p ? { registry: p.registry, name: p.name } : undefined; 49 + }, 50 + undefined, 51 + { equals: (a, b) => a?.registry === b?.registry && a?.name === b?.name }, 52 + ); 40 53 41 - const [result, { refetch }] = createQuery(packageName, (name) => initPackage(name), { 42 - keepPreviousData: false, 43 - }); 54 + const range = createMemo(() => parsed()?.range ?? 'latest'); 44 55 45 - createEffect(() => { 46 - const $result = result(); 47 - if (!$result) { 48 - return; 49 - } 56 + const [query, setQuery] = createDerivedSignal(() => params().q ?? ''); 50 57 51 - onCleanup(() => $result.worker.terminate()); 58 + const [manifest, { refetch }] = createQuery(identity, (id) => fetchPackageManifest(id.registry, id.name), { 59 + keepPreviousData: false, 52 60 }); 53 61 54 62 const recs = sample(RECOMMENDATIONS, 6) ··· 113 121 )} 114 122 115 123 <Switch> 116 - <Match when={result()} keyed> 117 - {(result) => <PackageResult result={result} />} 124 + <Match when={manifest()} keyed> 125 + {(m) => ( 126 + <PackageResult 127 + manifest={m} 128 + range={range()} 129 + onVersionChange={(version) => { 130 + setParams({ 131 + q: formatPackageSpecifier({ registry: m.registry, name: m.name, range: version }), 132 + }); 133 + }} 134 + /> 135 + )} 118 136 </Match> 119 137 120 - <Match when={result.state === 'errored'}> 138 + <Match when={manifest.state === 'errored'}> 121 139 <div class="flex flex-col items-center justify-center gap-3 py-12"> 122 140 <LucideCircleAlert class="text-danger-foreground-1 size-5" /> 123 - <span class="text-base-300 text-neutral-foreground-2">{result.error?.message}</span> 141 + <span class="text-base-300 text-neutral-foreground-2">{manifest.error?.message}</span> 124 142 <Button appearance="subtle" onClick={() => refetch()}> 125 143 Retry 126 144 </Button> 127 145 </div> 128 146 </Match> 129 147 130 - <Match when={result.state === 'pending' || result.state === 'refreshing'} keyed> 131 - {(_) => { 132 - const [progressState, setProgressState] = createSignal<ProgressMessage | null>(null); 133 - 134 - onCleanup(progress.listen((msg) => setProgressState(msg))); 135 - 136 - return ( 137 - <div class="flex flex-col items-center justify-center gap-3 py-12"> 138 - <LucideLoader class="size-5 animate-spin-linear text-neutral-foreground-3" /> 139 - 140 - {(() => { 141 - const p = progressState(); 142 - 143 - switch (p?.kind) { 144 - case 'resolve': 145 - return ( 146 - <span class="text-base-300 text-neutral-foreground-2"> 147 - Resolved {p.name}@{p.version} 148 - </span> 149 - ); 150 - case 'fetch': 151 - return ( 152 - <div class="flex flex-col items-center gap-1"> 153 - <span class="text-base-300 text-neutral-foreground-2">Downloaded {p.name}</span> 154 - <span class="text-base-200 text-neutral-foreground-3"> 155 - {p.current} / {p.total} 156 - </span> 157 - </div> 158 - ); 159 - default: 160 - return <span class="text-base-300 text-neutral-foreground-2">Loading...</span>; 161 - } 162 - })()} 163 - </div> 164 - ); 165 - }} 148 + <Match when={manifest.loading}> 149 + <div class="flex flex-col items-center justify-center gap-3 py-12"> 150 + <LucideLoader class="size-5 animate-spin-linear text-neutral-foreground-3" /> 151 + <span class="text-base-300 text-neutral-foreground-2">Loading...</span> 152 + </div> 166 153 </Match> 167 154 168 155 <Match when>
+153 -40
src/components/package-result.tsx
··· 1 - import { createSignal } from 'solid-js'; 1 + import { createEffect, createMemo, createSignal, For, Match, onCleanup, Switch } from 'solid-js'; 2 2 3 - import type { PackageSession } from '../npm/worker-client'; 3 + import { LucideCircleAlert, LucideLoader } from '../icons/lucide'; 4 + import { formatPackageSpecifier } from '../lib/package-name'; 5 + import { createQuery } from '../lib/query'; 6 + import { progress } from '../npm/events'; 7 + import { pickPackageVersion, type PackageManifest } from '../npm/packument'; 8 + import type { ProgressMessage } from '../npm/types'; 9 + import { initPackage } from '../npm/worker-client'; 10 + import Button from '../primitives/button'; 11 + import * as Dropdown from '../primitives/dropdown'; 4 12 import Toggle from '../primitives/toggle'; 5 13 6 14 import PackageBundle from './package-bundle'; ··· 9 17 // #region component 10 18 11 19 interface PackageResultProps { 12 - result: PackageSession; 20 + manifest: PackageManifest; 21 + range: string; 22 + onVersionChange: (version: string) => void; 13 23 } 14 24 15 25 const PackageResult = (props: PackageResultProps) => { 16 - const result = props.result; 26 + const manifest = props.manifest; 27 + 28 + const version = createMemo(() => pickPackageVersion(manifest, props.range)); 29 + 30 + const [session, { refetch }] = createQuery( 31 + () => { 32 + const picked = version(); 33 + return picked 34 + ? formatPackageSpecifier({ registry: manifest.registry, name: manifest.name, range: picked }) 35 + : undefined; 36 + }, 37 + (s) => initPackage(s), 38 + { keepPreviousData: false }, 39 + ); 17 40 18 - const [excludePeers, setExcludePeers] = createSignal(false); 41 + createEffect(() => { 42 + const $session = session(); 43 + if (!$session) { 44 + return; 45 + } 19 46 20 - const hasPeerDeps = result.peerDependencies.length > 0; 21 - const hasSubpaths = result.subpaths.defaultSubpath !== null; 47 + onCleanup(() => $session.worker.terminate()); 48 + }); 49 + 50 + const [excludePeers, setExcludePeers] = createSignal(false); 22 51 23 52 return ( 24 53 <div class="flex flex-col gap-4"> 25 - {/* package header */} 26 - <div class="flex flex-wrap gap-3"> 54 + <div class="flex flex-wrap items-baseline gap-3"> 27 55 <h2 class="min-w-0 text-base-600 font-bold wrap-break-word text-neutral-foreground-1"> 28 - {result.name} 56 + {manifest.name} 29 57 </h2> 30 58 31 - <span class="my-1.25 text-base-400 text-neutral-foreground-3">{result.version}</span> 59 + <Dropdown.Root value={version()} onValueChange={props.onVersionChange}> 60 + <Dropdown.Trigger>{version() ?? props.range}</Dropdown.Trigger> 61 + <Dropdown.Listbox> 62 + <For each={/* @once */ manifest.availableVersions}> 63 + {(v) => <Dropdown.Option value={v}>{v}</Dropdown.Option>} 64 + </For> 65 + </Dropdown.Listbox> 66 + </Dropdown.Root> 32 67 </div> 33 68 34 - {hasPeerDeps && ( 35 - <> 36 - <Toggle 37 - checked={excludePeers()} 38 - onChange={(ev) => setExcludePeers(ev.currentTarget.checked)} 39 - class="-mx-2" 40 - > 41 - Exclude peer dependencies 42 - </Toggle> 69 + <Switch> 70 + <Match when={!version()}> 71 + <div class="flex flex-col items-center justify-center gap-3 py-12"> 72 + <LucideCircleAlert class="text-danger-foreground-1 size-5" /> 73 + <span class="text-base-300 text-neutral-foreground-2"> 74 + No version of {manifest.name} satisfies {props.range} 75 + </span> 76 + </div> 77 + </Match> 78 + 79 + <Match when={session()} keyed> 80 + {(s) => { 81 + const hasPeerDeps = s.peerDependencies.length > 0; 82 + const hasSubpaths = s.subpaths.defaultSubpath !== null; 83 + 84 + return ( 85 + <> 86 + {s.description && <p class="text-base-300 text-neutral-foreground-2">{s.description}</p>} 87 + 88 + {hasPeerDeps && ( 89 + <> 90 + <Toggle 91 + checked={excludePeers()} 92 + onChange={(ev) => setExcludePeers(ev.currentTarget.checked)} 93 + class="-mx-2" 94 + > 95 + Exclude peer dependencies 96 + </Toggle> 97 + 98 + <hr class="mb-4 border-neutral-stroke-3" /> 99 + </> 100 + )} 101 + 102 + {hasSubpaths && ( 103 + <> 104 + <PackageBundle 105 + packageName={/* @once */ s.name} 106 + subpaths={/* @once */ s.subpaths} 107 + worker={/* @once */ s.worker} 108 + excludePeers={excludePeers()} 109 + peerDependencies={/* @once */ s.peerDependencies} 110 + /> 43 111 44 - <hr class="mb-4 border-neutral-stroke-3" /> 45 - </> 46 - )} 112 + <hr class="my-4 border-neutral-stroke-3" /> 113 + </> 114 + )} 47 115 48 - {hasSubpaths && ( 49 - <> 50 - <PackageBundle 51 - packageName={/* @once */ result.name} 52 - subpaths={/* @once */ result.subpaths} 53 - worker={/* @once */ result.worker} 54 - excludePeers={excludePeers()} 55 - peerDependencies={/* @once */ result.peerDependencies} 56 - /> 116 + <PackageDependencies 117 + packages={/* @once */ s.packages} 118 + installSize={/* @once */ s.installSize} 119 + excludePeers={excludePeers()} 120 + /> 121 + </> 122 + ); 123 + }} 124 + </Match> 57 125 58 - <hr class="my-4 border-neutral-stroke-3" /> 59 - </> 60 - )} 126 + <Match when={session.state === 'errored'}> 127 + <div class="flex flex-col items-center justify-center gap-3 py-12"> 128 + <LucideCircleAlert class="text-danger-foreground-1 size-5" /> 129 + <span class="text-base-300 text-neutral-foreground-2">{session.error?.message}</span> 130 + <Button appearance="subtle" onClick={() => refetch()}> 131 + Retry 132 + </Button> 133 + </div> 134 + </Match> 61 135 62 - <PackageDependencies 63 - packages={/* @once */ result.packages} 64 - installSize={/* @once */ result.installSize} 65 - excludePeers={excludePeers()} 66 - /> 136 + <Match when> 137 + <InstallProgress /> 138 + </Match> 139 + </Switch> 67 140 </div> 68 141 ); 69 142 }; ··· 71 144 export default PackageResult; 72 145 73 146 // #endregion 147 + 148 + // #region InstallProgress 149 + 150 + const InstallProgress = () => { 151 + const [progressState, setProgressState] = createSignal<ProgressMessage | null>(null); 152 + 153 + onCleanup(progress.listen((msg) => setProgressState(msg))); 154 + 155 + return ( 156 + <div class="flex flex-col items-center justify-center gap-3 py-12"> 157 + <LucideLoader class="size-5 animate-spin-linear text-neutral-foreground-3" /> 158 + 159 + {(() => { 160 + const p = progressState(); 161 + 162 + switch (p?.kind) { 163 + case 'resolve': 164 + return ( 165 + <span class="text-base-300 text-neutral-foreground-2"> 166 + Resolved {p.name}@{p.version} 167 + </span> 168 + ); 169 + case 'fetch': 170 + return ( 171 + <div class="flex flex-col items-center gap-1"> 172 + <span class="text-base-300 text-neutral-foreground-2">Downloaded {p.name}</span> 173 + <span class="text-base-200 text-neutral-foreground-3"> 174 + {p.current} / {p.total} 175 + </span> 176 + </div> 177 + ); 178 + default: 179 + return <span class="text-base-300 text-neutral-foreground-2">Installing...</span>; 180 + } 181 + })()} 182 + </div> 183 + ); 184 + }; 185 + 186 + // #endregion
+1
src/npm/lib/worker-entry.ts
··· 73 73 initResult = { 74 74 name: mainPackage.name, 75 75 version: mainPackage.version, 76 + description: manifest.description, 76 77 subpaths, 77 78 installSize, 78 79 packages,
+47
src/npm/packument.ts
··· 1 + import * as semver from 'semver'; 2 + 3 + import type { Registry } from '../lib/package-name'; 4 + 5 + import { fetchPackument } from './lib/registry'; 6 + import { pickVersion } from './lib/resolve'; 7 + import type { AbbreviatedManifest } from './lib/types'; 8 + 9 + /** 10 + * resolved packument-level metadata for a package. 11 + * fetched on the main thread so the package header (with a version switcher) 12 + * can render before the bundler worker is spawned. keyed on registry + name 13 + * only — version selection happens separately so switching versions doesn't 14 + * refetch the packument or remount the header. 15 + */ 16 + export interface PackageManifest { 17 + registry: Registry; 18 + name: string; 19 + versions: Record<string, AbbreviatedManifest>; 20 + distTags: Record<string, string>; 21 + /** all available versions, sorted newest first */ 22 + availableVersions: string[]; 23 + } 24 + 25 + /** 26 + * fetches the packument for a package. the browser's http cache handles 27 + * deduping with the worker's own packument fetch during dependency resolution. 28 + */ 29 + export async function fetchPackageManifest(registry: Registry, name: string): Promise<PackageManifest> { 30 + const packument = await fetchPackument(name, registry); 31 + const availableVersions = Object.keys(packument.versions).toSorted(semver.rcompare); 32 + 33 + return { 34 + registry, 35 + name, 36 + versions: packument.versions, 37 + distTags: packument['dist-tags'], 38 + availableVersions, 39 + }; 40 + } 41 + 42 + /** 43 + * resolves a range against a package manifest, returning the matching version string. 44 + */ 45 + export function pickPackageVersion(manifest: PackageManifest, range: string): string | undefined { 46 + return pickVersion(manifest.versions, manifest.distTags, range)?.version; 47 + }
+1
src/npm/types.ts
··· 72 72 const initResultSchema = v.object({ 73 73 name: v.string(), 74 74 version: v.string(), 75 + description: v.optional(v.string()), 75 76 subpaths: discoveredSubpathsSchema, 76 77 installSize: v.number(), 77 78 packages: v.array(installedPackageSchema),