···11+teardown is a bundlephobia alternative built with @rolldown/browser, using Vite and Solid.js.
22+33+## development notes
44+55+### project management
66+77+- pnpm is managed by mise, to run commands, use `mise exec -- pnpm ...`
88+- install dependencies with `pnpm install`
99+- run dev server with `pnpm dev`
1010+- build with `pnpm build`
1111+- preview production build with `pnpm preview`
1212+- format via `pnpm run fmt` (oxfmt)
1313+- lint via `pnpm run lint` (oxlint)
1414+- typecheck via `pnpm tsc -b`
1515+- check `pnpm view <package>` before adding a new dependency
1616+1717+### code writing
1818+1919+- new files should be in kebab-case
2020+- use tabs for indentation, spaces allowed for diagrams in comments
2121+- use single quotes and add trailing commas
2222+- prefer arrow functions, but use regular methods in classes unless arrow functions are necessary
2323+ (e.g., when passing the method as a callback that needs `this` binding)
2424+- use braces for control statements, even single-line bodies
2525+- use bare blocks `{ }` to group related code and limit variable scope
2626+- use template literals for user-facing strings and error messages
2727+- avoid barrel exports (index files that re-export from other modules); import directly from source
2828+- use `// #region <name>` and `// #endregion` to denote regions when a file needs to contain a lot
2929+ of code
3030+- prefer required parameters over optional ones; optional parameters are acceptable when:
3131+ - the default is obvious and used by the vast majority of callers (e.g., `encoding = 'utf-8'`)
3232+ - it's a configuration value with a sensible default (e.g., `timeout = 5000`)
3333+- avoid optional parameters that change behavioral modes or make the function do different things
3434+ based on presence/absence; prefer separate functions instead
3535+- when adding optional parameters for backwards compatibility, consider whether a new function with
3636+ a clearer name would be better
3737+3838+### documentation
3939+4040+- documentations include README, code comments, commit messages
4141+- any writing should be in lowercase, except for proper nouns, acronyms and 'I'; this does not apply
4242+ to public-facing interfaces like web UI
4343+- only comment non-trivial code, focusing on _why_ rather than _what_
4444+- write comments and JSDoc in lowercase (except proper nouns, acronyms, and 'I')
4545+- add JSDoc comments to new publicly exported functions, methods, classes, fields, and enums
4646+- JSDoc should include proper annotations:
4747+ - use `@param` for parameters (no dashes after param names)
4848+ - use `@returns` for return values
4949+ - use `@throws` for exceptions when applicable
5050+ - keep descriptions concise but informative
5151+5252+### agentic coding
5353+5454+- `.research/` directory in the project root serves as a workspace for temporary experiments,
5555+ analysis, and planning materials. create if not present (it's gitignored). this directory may
5656+ contain cloned repositories or other reference materials that can help inform implementation
5757+ decisions
5858+- this document is intentionally incomplete; discover everything else in the repo
5959+- don't make assumptions or speculate about code, plans, or requirements without exploring first;
6060+ pause and ask for clarification when you're still unsure after looking into it
6161+- in plan mode, present the plan for review before exiting to allow for feedback or follow-up
6262+ questions
6363+- when debugging problems, isolate the root cause first before attempting fixes: add logging,
6464+ reproduce the issue, narrow down the scope, and confirm the exact source of the problem
6565+6666+### Claude Code-specific
6767+6868+- Bash tool persists directory changes (`cd`) across calls; always specify cd with absolute paths to
6969+ be sure
7070+- Task tool (subagents for exploration, planning, etc.) may not always be accurate; verify subagent
7171+ findings when needed
7272+7373+### cgr
7474+7575+use `@oomfware/cgr` to ask questions about external repositories.
7676+7777+```
7878+npx @oomfware/cgr ask [options] <repo>[#branch] <question>
7979+8080+options:
8181+ -m, --model <model> model to use: opus, sonnet, haiku (default: haiku)
8282+ -d, --deep clone full history (enables git log/blame/show)
8383+ -w, --with <repo> additional repository to include, supports #branch (repeatable)
8484+```
8585+8686+useful repositories:
8787+8888+- `github.com/rolldown/rolldown` for Rolldown bundler, @rolldown/browser API
8989+- `github.com/solidjs/solid` for Solid.js core reactivity and components
9090+- `github.com/vitejs/vite` for Vite dev server, build tooling, plugin API
9191+- `github.com/oxc-project/oxc` for Oxlint linter, Oxfmt formatter
9292+9393+cgr works best with detailed questions. include file/folder paths when you know them, and reference
9494+details from previous answers in follow-ups.
9595+9696+run `npx @oomfware/cgr --help` for more options.
+14
LICENSE
···11+BSD Zero Clause License
22+33+Copyright (c) 2026 Mary
44+55+Permission to use, copy, modify, and/or distribute this software for any
66+purpose with or without fee is hereby granted.
77+88+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
99+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
1010+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
1111+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
1212+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
1313+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
1414+PERFORMANCE OF THIS SOFTWARE.
+28
README.md
···11+## Usage
22+33+```bash
44+$ npm install # or pnpm install or yarn install
55+```
66+77+### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
88+99+## Available Scripts
1010+1111+In the project directory, you can run:
1212+1313+### `npm run dev`
1414+1515+Runs the app in the development mode.<br> Open [http://localhost:5173](http://localhost:5173) to
1616+view it in the browser.
1717+1818+### `npm run build`
1919+2020+Builds the app for production to the `dist` folder.<br> It correctly bundles Solid in production
2121+mode and optimizes the build for the best performance.
2222+2323+The build is minified and the filenames include the hashes.<br> Your app is ready to be deployed!
2424+2525+## Deployment
2626+2727+Learn more about deploying your application with the
2828+[documentations](https://vite.dev/guide/static-deploy.html)
···11+import { dequal } from 'dequal';
22+import { createSignal, onCleanup, type Accessor } from 'solid-js';
33+import * as v from 'valibot';
44+55+/** schema definition for search params - each schema must accept string input */
66+type SearchParamsDefinition = Record<string, v.GenericSchema<string | string[] | undefined, unknown>>;
77+88+export interface UseSearchParamsOptions {
99+ /** use replaceState instead of pushState when updating URL */
1010+ replace?: boolean;
1111+}
1212+1313+/** infers output type with all fields optional */
1414+type InferSearchParamsOutput<T extends SearchParamsDefinition> = {
1515+ [K in keyof T]: v.InferOutput<T[K]> | undefined;
1616+};
1717+1818+/**
1919+ * creates a reactive signal for validated URL search parameters.
2020+ * all parameters are optional. array schemas automatically use getAll().
2121+ *
2222+ * @param definition record of valibot schemas (each should accept string input)
2323+ * @param options configuration options
2424+ * @returns a tuple of [params accessor, setParams function]
2525+ *
2626+ * @example
2727+ * ```ts
2828+ * const [params, setParams] = useSearchParams({
2929+ * q: v.string(),
3030+ * page: v.pipe(v.string(), v.transform(Number)),
3131+ * tags: v.array(v.string()),
3232+ * });
3333+ *
3434+ * params().q // string | undefined
3535+ * params().page // number | undefined
3636+ * params().tags // string[] | undefined
3737+ * ```
3838+ */
3939+export const useSearchParams = <T extends SearchParamsDefinition>(
4040+ definition: T,
4141+ options?: UseSearchParamsOptions,
4242+): [Accessor<InferSearchParamsOutput<T>>, (params: Partial<InferSearchParamsOutput<T>>) => void] => {
4343+ const getValidatedParams = () => {
4444+ const searchParams = new URLSearchParams(window.location.search);
4545+ const result: Record<string, unknown> = {};
4646+4747+ for (const key in definition) {
4848+ const schema = definition[key];
4949+5050+ let raw: string | string[] | undefined;
5151+ if (schema.type === 'array') {
5252+ const values = searchParams.getAll(key);
5353+ raw = values.length > 0 ? values : undefined;
5454+ } else {
5555+ raw = searchParams.get(key) ?? undefined;
5656+ }
5757+5858+ if (raw === undefined) {
5959+ result[key] = undefined;
6060+ } else {
6161+ const parsed = v.safeParse(schema, raw);
6262+ result[key] = parsed.success ? parsed.output : undefined;
6363+ }
6464+ }
6565+6666+ return result as InferSearchParamsOutput<T>;
6767+ };
6868+6969+ const [params, setParamsInternal] = createSignal(getValidatedParams());
7070+7171+ const handlePopState = () => {
7272+ setParamsInternal(() => getValidatedParams());
7373+ };
7474+7575+ window.addEventListener('popstate', handlePopState);
7676+ onCleanup(() => window.removeEventListener('popstate', handlePopState));
7777+7878+ const setParams = (newParams: Partial<InferSearchParamsOutput<T>>) => {
7979+ const current = params();
8080+ const merged = { ...current, ...newParams };
8181+8282+ const result: Record<string, unknown> = {};
8383+ const searchParams = new URLSearchParams(window.location.search);
8484+8585+ for (const key in definition) {
8686+ searchParams.delete(key);
8787+8888+ const value = merged[key];
8989+ if (value === undefined) {
9090+ result[key] = undefined;
9191+ continue;
9292+ }
9393+9494+ const parsed = v.safeParse(definition[key], value);
9595+ if (!parsed.success) {
9696+ result[key] = undefined;
9797+ continue;
9898+ }
9999+100100+ result[key] = parsed.output;
101101+ if (Array.isArray(parsed.output)) {
102102+ for (const item of parsed.output) {
103103+ searchParams.append(key, String(item));
104104+ }
105105+ } else {
106106+ searchParams.set(key, String(parsed.output));
107107+ }
108108+ }
109109+110110+ const validated = result as InferSearchParamsOutput<T>;
111111+112112+ if (dequal(current, validated)) {
113113+ return;
114114+ }
115115+116116+ const url = new URL(window.location.href);
117117+ url.search = searchParams.toString();
118118+119119+ if (options?.replace) {
120120+ history.replaceState(null, '', url);
121121+ } else {
122122+ history.pushState(null, '', url);
123123+ }
124124+125125+ setParamsInternal(() => validated);
126126+ };
127127+128128+ return [params, setParams];
129129+};
+291
src/npm/bundler.ts
···11+import { getUtf8Length } from '@atcute/uint8array';
22+import { rolldown } from '@rolldown/browser';
33+import { memfs } from '@rolldown/browser/experimental';
44+55+import { BundleError } from './errors';
66+import { progress } from './events';
77+88+const { volume } = memfs!;
99+1010+// #region types
1111+1212+/**
1313+ * options for bundling.
1414+ */
1515+export interface BundleOptions {
1616+ /** additional rolldown options */
1717+ rolldown?: {
1818+ /** external packages to exclude from bundle */
1919+ external?: string[];
2020+ /** whether to minify */
2121+ minify?: boolean;
2222+ };
2323+}
2424+2525+/**
2626+ * a bundled chunk.
2727+ */
2828+export interface BundleChunk {
2929+ /** chunk filename */
3030+ fileName: string;
3131+ /** the bundled code */
3232+ code: string;
3333+ /** raw size in bytes */
3434+ size: number;
3535+ /** gzipped size in bytes */
3636+ gzipSize: number;
3737+ /** brotli size in bytes, if supported */
3838+ brotliSize?: number;
3939+ /** whether this is the entry chunk */
4040+ isEntry: boolean;
4141+ /** exported names from this chunk */
4242+ exports: string[];
4343+}
4444+4545+/**
4646+ * result of bundling a package.
4747+ */
4848+export interface BundleResult {
4949+ /** all output chunks */
5050+ chunks: BundleChunk[];
5151+ /** total raw size in bytes (all chunks) */
5252+ size: number;
5353+ /** total gzipped size in bytes (all chunks) */
5454+ gzipSize: number;
5555+ /** total brotli size in bytes (all chunks), if supported */
5656+ brotliSize?: number;
5757+ /** exported names from the entry chunk */
5858+ exports: string[];
5959+}
6060+6161+// #endregion
6262+6363+// #region helpers
6464+6565+const VIRTUAL_ENTRY_ID = '\0virtual:entry';
6666+6767+/**
6868+ * checks if a file likely has a default export.
6969+ * looks for common patterns in ESM and CJS.
7070+ */
7171+function hasDefaultExport(source: string): boolean {
7272+ // ESM patterns
7373+ if (/\bexport\s+default\b/.test(source)) {
7474+ return true;
7575+ }
7676+ if (/\bexport\s*\{\s*[^}]*\bdefault\b/.test(source)) {
7777+ return true;
7878+ }
7979+ // CJS patterns (bundlers typically convert these to default exports)
8080+ if (/\bmodule\.exports\s*=/.test(source)) {
8181+ return true;
8282+ }
8383+ if (/\bexports\.default\s*=/.test(source)) {
8484+ return true;
8585+ }
8686+ return false;
8787+}
8888+8989+/**
9090+ * creates a virtual entry point that imports and re-exports from a specific subpath.
9191+ *
9292+ * @param packageName the package name
9393+ * @param subpath the export subpath (e.g., ".", "./utils")
9494+ * @param selectedExports list of specific exports to include, or null for all
9595+ * @param includeDefault whether to include default export (only used when selectedExports is null)
9696+ * @returns the entry point code
9797+ */
9898+function createVirtualEntry(
9999+ packageName: string,
100100+ subpath: string,
101101+ selectedExports: string[] | null,
102102+ includeDefault: boolean,
103103+): string {
104104+ const importPath = subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`;
105105+106106+ if (selectedExports === null) {
107107+ // re-export everything
108108+ let code = `export * from '${importPath}';\n`;
109109+ if (includeDefault) {
110110+ code += `export { default } from '${importPath}';\n`;
111111+ }
112112+ return code;
113113+ }
114114+115115+ // specific exports selected (empty array = export nothing)
116116+ // quote names to handle non-identifier exports
117117+ const quoted = selectedExports.map((e) => JSON.stringify(e));
118118+ return `export { ${quoted.join(', ')} } from '${importPath}';\n`;
119119+}
120120+121121+/**
122122+ * get compressed size using a compression stream.
123123+ */
124124+async function getCompressedSize(code: string, format: CompressionFormat): Promise<number> {
125125+ const stream = new Blob([code]).stream();
126126+ const compressed = stream.pipeThrough(new CompressionStream(format));
127127+ const reader = compressed.getReader();
128128+129129+ let size = 0;
130130+ while (true) {
131131+ const { done, value } = await reader.read();
132132+ if (done) {
133133+ break;
134134+ }
135135+136136+ size += value.byteLength;
137137+ }
138138+139139+ return size;
140140+}
141141+142142+/**
143143+ * get gzip size using compression stream.
144144+ */
145145+async function getGzipSize(code: string): Promise<number> {
146146+ return getCompressedSize(code, 'gzip');
147147+}
148148+149149+/**
150150+ * whether brotli compression is supported.
151151+ * - `undefined`: not yet checked
152152+ * - `true`: supported
153153+ * - `false`: not supported
154154+ */
155155+export let isBrotliSupported: boolean | undefined;
156156+157157+/**
158158+ * get brotli size using compression stream, if supported.
159159+ * returns `undefined` if brotli is not supported by the browser.
160160+ */
161161+export async function getBrotliSize(code: string): Promise<number | undefined> {
162162+ if (isBrotliSupported === false) {
163163+ return undefined;
164164+ }
165165+166166+ if (isBrotliSupported === undefined) {
167167+ try {
168168+ // @ts-expect-error 'br' is not in the type definition yet
169169+ const size = await getCompressedSize(code, 'br');
170170+ isBrotliSupported = true;
171171+ return size;
172172+ } catch {
173173+ isBrotliSupported = false;
174174+ return undefined;
175175+ }
176176+ }
177177+178178+ // @ts-expect-error 'br' is not in the type definition yet
179179+ return getCompressedSize(code, 'br');
180180+}
181181+182182+// #endregion
183183+184184+// #region core
185185+186186+/**
187187+ * bundles a subpath from a package that's already loaded in rolldown's memfs.
188188+ *
189189+ * @param packageName the package name (e.g., "react")
190190+ * @param subpath the export subpath to bundle (e.g., ".", "./utils")
191191+ * @param selectedExports specific exports to include, or null for all
192192+ * @param options bundling options
193193+ * @returns bundle result with chunks, sizes, and exported names
194194+ */
195195+export async function bundlePackage(
196196+ packageName: string,
197197+ subpath: string,
198198+ selectedExports: string[] | null,
199199+ options: BundleOptions,
200200+): Promise<BundleResult> {
201201+ // bundle with rolldown
202202+ const bundle = await rolldown({
203203+ input: { main: VIRTUAL_ENTRY_ID },
204204+ cwd: '/',
205205+ external: options.rolldown?.external,
206206+ plugins: [
207207+ {
208208+ name: 'virtual-entry',
209209+ resolveId(id: string) {
210210+ if (id === VIRTUAL_ENTRY_ID) {
211211+ return id;
212212+ }
213213+ },
214214+ async load(id: string) {
215215+ if (id !== VIRTUAL_ENTRY_ID) {
216216+ return;
217217+ }
218218+219219+ // check if the module has a default export
220220+ let includeDefault = false;
221221+ if (selectedExports === null) {
222222+ const importPath = subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`;
223223+ const resolved = await this.resolve(importPath);
224224+225225+ if (resolved) {
226226+ try {
227227+ const source = volume.readFileSync(resolved.id, 'utf8') as string;
228228+ includeDefault = hasDefaultExport(source);
229229+ } catch {
230230+ // couldn't read file, skip default export
231231+ }
232232+ }
233233+ }
234234+235235+ return createVirtualEntry(packageName, subpath, selectedExports, includeDefault);
236236+ },
237237+ },
238238+ ],
239239+ });
240240+241241+ const output = await bundle.generate({
242242+ format: 'esm',
243243+ minify: options.rolldown?.minify ?? true,
244244+ });
245245+246246+ // process all chunks
247247+ const rawChunks = output.output.filter((o) => o.type === 'chunk');
248248+249249+ progress.emit({ type: 'progress', kind: 'compress' });
250250+251251+ const chunks: BundleChunk[] = await Promise.all(
252252+ rawChunks.map(async (chunk) => {
253253+ const code = chunk.code;
254254+ const size = getUtf8Length(code);
255255+ const [gzipSize, brotliSize] = await Promise.all([getGzipSize(code), getBrotliSize(code)]);
256256+257257+ return {
258258+ fileName: chunk.fileName,
259259+ code,
260260+ size,
261261+ gzipSize,
262262+ brotliSize,
263263+ isEntry: chunk.isEntry,
264264+ exports: chunk.exports || [],
265265+ };
266266+ }),
267267+ );
268268+269269+ // find entry chunk for exports
270270+ const entryChunk = chunks.find((c) => c.isEntry);
271271+ if (!entryChunk) {
272272+ throw new BundleError('no entry chunk found in bundle output');
273273+ }
274274+275275+ // aggregate sizes
276276+ const totalSize = chunks.reduce((acc, c) => acc + c.size, 0);
277277+ const totalGzipSize = chunks.reduce((acc, c) => acc + c.gzipSize, 0);
278278+ const totalBrotliSize = isBrotliSupported ? chunks.reduce((acc, c) => acc + c.brotliSize!, 0) : undefined;
279279+280280+ await bundle.close();
281281+282282+ return {
283283+ chunks,
284284+ size: totalSize,
285285+ gzipSize: totalGzipSize,
286286+ brotliSize: totalBrotliSize,
287287+ exports: entryChunk.exports,
288288+ };
289289+}
290290+291291+// #endregion
+79
src/npm/errors.ts
···11+/**
22+ * base class for all teardown errors.
33+ */
44+export class TeardownError extends Error {
55+ constructor(message: string) {
66+ super(message);
77+ this.name = 'TeardownError';
88+ }
99+}
1010+1111+/**
1212+ * thrown when a package cannot be found in the registry.
1313+ */
1414+export class PackageNotFoundError extends TeardownError {
1515+ readonly packageName: string;
1616+ readonly registry: string;
1717+1818+ constructor(packageName: string, registry: string) {
1919+ super(`package not found: ${packageName}`);
2020+ this.name = 'PackageNotFoundError';
2121+ this.packageName = packageName;
2222+ this.registry = registry;
2323+ }
2424+}
2525+2626+/**
2727+ * thrown when no version of a package satisfies the requested range.
2828+ */
2929+export class NoMatchingVersionError extends TeardownError {
3030+ readonly packageName: string;
3131+ readonly range: string;
3232+3333+ constructor(packageName: string, range: string) {
3434+ super(`no version of ${packageName} satisfies ${range}`);
3535+ this.name = 'NoMatchingVersionError';
3636+ this.packageName = packageName;
3737+ this.range = range;
3838+ }
3939+}
4040+4141+/**
4242+ * thrown when a package specifier is malformed.
4343+ */
4444+export class InvalidSpecifierError extends TeardownError {
4545+ readonly specifier: string;
4646+4747+ constructor(specifier: string, reason?: string) {
4848+ super(reason ? `invalid specifier: ${specifier} (${reason})` : `invalid specifier: ${specifier}`);
4949+ this.name = 'InvalidSpecifierError';
5050+ this.specifier = specifier;
5151+ }
5252+}
5353+5454+/**
5555+ * thrown when a network request fails.
5656+ */
5757+export class FetchError extends TeardownError {
5858+ readonly url: string;
5959+ readonly status: number;
6060+ readonly statusText: string;
6161+6262+ constructor(url: string, status: number, statusText: string) {
6363+ super(`fetch failed: ${status} ${statusText}`);
6464+ this.name = 'FetchError';
6565+ this.url = url;
6666+ this.status = status;
6767+ this.statusText = statusText;
6868+ }
6969+}
7070+7171+/**
7272+ * thrown when bundling fails.
7373+ */
7474+export class BundleError extends TeardownError {
7575+ constructor(message: string) {
7676+ super(message);
7777+ this.name = 'BundleError';
7878+ }
7979+}
+8
src/npm/events.ts
···11+import { createEventEmitter } from '../lib/emitter';
22+33+import type { ProgressMessage } from './worker-protocol';
44+55+/**
66+ * emitted during package initialization and bundling.
77+ */
88+export const progress = createEventEmitter<[ProgressMessage]>();
+156
src/npm/fetch.test.ts
···11+import { Volume } from 'memfs';
22+import { describe, expect, it } from 'vitest';
33+44+import { DEFAULT_EXCLUDE_PATTERNS, fetchPackagesToVolume } from './fetch';
55+import { hoist } from './hoist';
66+import { resolve } from './resolve';
77+88+describe('fetchPackagesToVolume', () => {
99+ it('fetches and extracts a simple package', async () => {
1010+ // resolve a tiny package
1111+ const result = await resolve(['is-odd@3.0.1'], { installPeers: false });
1212+ const hoisted = hoist(result.roots);
1313+ const volume = new Volume();
1414+ await fetchPackagesToVolume(hoisted, volume);
1515+1616+ // should have files from is-odd
1717+ const isOddPackageJson = volume.readFileSync('/node_modules/is-odd/package.json', 'utf8');
1818+ expect(isOddPackageJson).toBeDefined();
1919+2020+ // verify it's valid JSON
2121+ const json = JSON.parse(isOddPackageJson as string);
2222+ expect(json.name).toBe('is-odd');
2323+2424+ // should also have is-number (dependency)
2525+ const isNumberPackageJson = volume.readFileSync('/node_modules/is-number/package.json', 'utf8');
2626+ expect(isNumberPackageJson).toBeDefined();
2727+ });
2828+2929+ it('respects concurrency limit', async () => {
3030+ const result = await resolve(['is-odd@3.0.1'], { installPeers: false });
3131+ const hoisted = hoist(result.roots);
3232+ const volume = new Volume();
3333+3434+ // should work with concurrency of 1
3535+ await fetchPackagesToVolume(hoisted, volume, { concurrency: 1 });
3636+ const files = volume.toJSON();
3737+ expect(Object.keys(files).length).toBeGreaterThan(0);
3838+ });
3939+4040+ it('excludes files matching default patterns', async () => {
4141+ const result = await resolve(['is-odd@3.0.1'], { installPeers: false });
4242+ const hoisted = hoist(result.roots);
4343+ const volume = new Volume();
4444+ await fetchPackagesToVolume(hoisted, volume);
4545+4646+ // should not have README or LICENSE
4747+ const files = volume.toJSON();
4848+ for (const path of Object.keys(files)) {
4949+ const filename = path.split('/').pop()!;
5050+ expect(filename.toUpperCase()).not.toMatch(/^README/);
5151+ expect(filename.toUpperCase()).not.toMatch(/^LICENSE/);
5252+ }
5353+ });
5454+5555+ it('can disable exclusions with empty array', async () => {
5656+ const result = await resolve(['is-odd@3.0.1'], { installPeers: false });
5757+ const hoisted = hoist(result.roots);
5858+5959+ const volumeNoExclude = new Volume();
6060+ await fetchPackagesToVolume(hoisted, volumeNoExclude, { exclude: [] });
6161+6262+ const volumeWithExclude = new Volume();
6363+ await fetchPackagesToVolume(hoisted, volumeWithExclude);
6464+6565+ // should have more files when nothing is excluded
6666+ const noExcludeCount = Object.keys(volumeNoExclude.toJSON()).length;
6767+ const withExcludeCount = Object.keys(volumeWithExclude.toJSON()).length;
6868+ expect(noExcludeCount).toBeGreaterThanOrEqual(withExcludeCount);
6969+ });
7070+});
7171+7272+describe('unpackedSize calculation', () => {
7373+ it('populates unpackedSize from tarball when registry does not provide it', async () => {
7474+ // JSR packages don't have unpackedSize in registry metadata
7575+ const result = await resolve(['jsr:@luca/flag@1.0.1']);
7676+ const hoisted = hoist(result.roots);
7777+ const volume = new Volume();
7878+7979+ // before fetch, unpackedSize should be undefined (JSR doesn't provide it)
8080+ const rootNode = hoisted.root.get('@luca/flag')!;
8181+ expect(rootNode.unpackedSize).toBeUndefined();
8282+8383+ await fetchPackagesToVolume(hoisted, volume);
8484+8585+ // after fetch, unpackedSize should be populated from tarball
8686+ expect(rootNode.unpackedSize).toBeGreaterThan(0);
8787+ });
8888+8989+ it('preserves registry-provided unpackedSize for npm packages', async () => {
9090+ const result = await resolve(['is-odd@3.0.1'], { installPeers: false });
9191+ const hoisted = hoist(result.roots);
9292+ const volume = new Volume();
9393+9494+ // npm registry provides unpackedSize
9595+ const rootNode = hoisted.root.get('is-odd')!;
9696+ const registrySize = rootNode.unpackedSize;
9797+ expect(registrySize).toBeGreaterThan(0);
9898+9999+ await fetchPackagesToVolume(hoisted, volume);
100100+101101+ // should preserve the registry-provided size
102102+ expect(rootNode.unpackedSize).toBe(registrySize);
103103+ });
104104+105105+ it('includes excluded files in size calculation', async () => {
106106+ const result = await resolve(['is-odd@3.0.1'], { installPeers: false });
107107+ const hoisted = hoist(result.roots);
108108+109109+ // clear the registry-provided size to force calculation from tarball
110110+ const rootNode = hoisted.root.get('is-odd')!;
111111+ rootNode.unpackedSize = undefined;
112112+113113+ const volume = new Volume();
114114+ await fetchPackagesToVolume(hoisted, volume);
115115+116116+ // size should include README, LICENSE, etc. even though they're excluded from extraction
117117+ const extractedFiles = volume.toJSON();
118118+ const extractedSize = Object.values(extractedFiles).reduce(
119119+ (sum, content) => sum + (content as string).length,
120120+ 0,
121121+ );
122122+123123+ // tarball size should be >= extracted size (includes excluded files)
124124+ expect(rootNode.unpackedSize).toBeGreaterThanOrEqual(extractedSize);
125125+ });
126126+});
127127+128128+describe('DEFAULT_EXCLUDE_PATTERNS', () => {
129129+ it('matches README files', () => {
130130+ expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('README.md'))).toBe(true);
131131+ expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('README'))).toBe(true);
132132+ expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('readme.txt'))).toBe(true);
133133+ });
134134+135135+ it('matches LICENSE files', () => {
136136+ expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('LICENSE'))).toBe(true);
137137+ expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('LICENSE.md'))).toBe(true);
138138+ expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('LICENCE'))).toBe(true);
139139+ });
140140+141141+ it('matches test directories', () => {
142142+ expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('__tests__/foo.js'))).toBe(true);
143143+ expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('test/index.js'))).toBe(true);
144144+ });
145145+146146+ it('matches source maps', () => {
147147+ expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('index.js.map'))).toBe(true);
148148+ expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('dist/bundle.js.map'))).toBe(true);
149149+ });
150150+151151+ it('does not match source files', () => {
152152+ expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('index.js'))).toBe(false);
153153+ expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('src/utils.ts'))).toBe(false);
154154+ expect(DEFAULT_EXCLUDE_PATTERNS.some((p) => p.test('package.json'))).toBe(false);
155155+ });
156156+});
+192
src/npm/fetch.ts
···11+import { untar } from '@mary/tar';
22+import type { Volume } from 'memfs';
33+44+import { FetchError } from './errors';
55+import { progress } from './events';
66+import type { HoistedNode, HoistedResult } from './types';
77+88+/**
99+ * options for fetching packages.
1010+ */
1111+export interface FetchOptions {
1212+ /** max concurrent fetches (default 6) */
1313+ concurrency?: number;
1414+ /** regex patterns for files to exclude (matched against path after "package/" prefix is stripped) */
1515+ exclude?: RegExp[];
1616+}
1717+1818+/**
1919+ * default patterns for files that are not needed for bundling.
2020+ * matches against the file path within the package (e.g., "README.md", "docs/guide.md")
2121+ */
2222+export const DEFAULT_EXCLUDE_PATTERNS: RegExp[] = [
2323+ // docs and meta files
2424+ /^README(\..*)?$/i,
2525+ /^LICENSE(\..*)?$/i,
2626+ /^LICENCE(\..*)?$/i,
2727+ /^CHANGELOG(\..*)?$/i,
2828+ /^HISTORY(\..*)?$/i,
2929+ /^CONTRIBUTING(\..*)?$/i,
3030+ /^AUTHORS(\..*)?$/i,
3131+ /^SECURITY(\..*)?$/i,
3232+ /^CODE_OF_CONDUCT(\..*)?$/i,
3333+ /^\.github\//,
3434+ /^\.vscode\//,
3535+ /^\.idea\//,
3636+ /^docs?\//i,
3737+3838+ // test files
3939+ /^__tests__\//,
4040+ /^tests?\//i,
4141+ /^specs?\//i,
4242+ /\.(test|spec)\.[jt]sx?$/,
4343+ /\.stories\.[jt]sx?$/,
4444+4545+ // config files
4646+ /^\..+rc(\..*)?$/,
4747+ /^\.editorconfig$/,
4848+ /^\.gitignore$/,
4949+ /^\.npmignore$/,
5050+ /^\.eslint/,
5151+ /^\.prettier/,
5252+ /^tsconfig(\..+)?\.json$/,
5353+ /^jest\.config/,
5454+ /^vitest\.config/,
5555+ /^rollup\.config/,
5656+ /^webpack\.config/,
5757+ /^vite\.config/,
5858+ /^babel\.config/,
5959+6060+ // source maps (usually not needed in bundling)
6161+ /\.map$/,
6262+];
6363+6464+/**
6565+ * fetches a tarball and writes its contents directly to a volume.
6666+ * handles gzip decompression and strips the "package/" prefix from paths.
6767+ *
6868+ * @param url the tarball URL
6969+ * @param destPath the destination path in the volume (e.g., "/node_modules/react")
7070+ * @param volume the volume to write to
7171+ * @param exclude regex patterns for files to skip
7272+ * @returns the total size of extracted files in bytes
7373+ */
7474+async function fetchTarballToVolume(
7575+ url: string,
7676+ destPath: string,
7777+ volume: Volume,
7878+ exclude: RegExp[] = [],
7979+): Promise<number> {
8080+ const response = await fetch(url);
8181+ if (!response.ok) {
8282+ throw new FetchError(url, response.status, response.statusText);
8383+ }
8484+8585+ const body = response.body;
8686+ if (!body) {
8787+ throw new FetchError(url, 0, 'response has no body');
8888+ }
8989+9090+ // decompress gzip -> extract tar
9191+ const decompressed = body.pipeThrough(new DecompressionStream('gzip'));
9292+9393+ let totalSize = 0;
9494+9595+ for await (const entry of untar(decompressed)) {
9696+ // skip directories
9797+ if (entry.type !== 'file') {
9898+ continue;
9999+ }
100100+101101+ // count size from tar header for all files (including excluded ones)
102102+ totalSize += entry.size;
103103+104104+ // npm tarballs have files under "package/" prefix - strip it
105105+ let path = entry.name;
106106+ if (path.startsWith('package/')) {
107107+ path = path.slice(8);
108108+ }
109109+110110+ // check if file should be excluded (skip extraction but size already counted)
111111+ if (exclude.some((pattern) => pattern.test(path))) {
112112+ continue;
113113+ }
114114+115115+ const content = await entry.bytes();
116116+ const fullPath = `${destPath}/${path}`;
117117+118118+ // ensure parent directories exist
119119+ const parentDir = fullPath.slice(0, fullPath.lastIndexOf('/'));
120120+ if (!volume.existsSync(parentDir)) {
121121+ volume.mkdirSync(parentDir, { recursive: true });
122122+ }
123123+124124+ volume.writeFileSync(fullPath, content);
125125+ }
126126+127127+ return totalSize;
128128+}
129129+130130+/**
131131+ * fetches all packages in a hoisted result and writes them to a volume.
132132+ * uses default exclude patterns to skip unnecessary files.
133133+ *
134134+ * @param hoisted the hoisted package structure
135135+ * @param volume the volume to write to
136136+ * @param options fetch options
137137+ */
138138+export async function fetchPackagesToVolume(
139139+ hoisted: HoistedResult,
140140+ volume: Volume,
141141+ options: FetchOptions = {},
142142+): Promise<void> {
143143+ const concurrency = options.concurrency ?? 6;
144144+ const exclude = options.exclude ?? DEFAULT_EXCLUDE_PATTERNS;
145145+ const queue: Array<{ node: HoistedNode; basePath: string }> = [];
146146+147147+ // collect all nodes into a flat queue
148148+ function collectNodes(node: HoistedNode, basePath: string): void {
149149+ queue.push({ node, basePath });
150150+ if (node.nested.size > 0) {
151151+ const nestedBasePath = `${basePath}/${node.name}/node_modules`;
152152+ for (const nested of node.nested.values()) {
153153+ collectNodes(nested, nestedBasePath);
154154+ }
155155+ }
156156+ }
157157+158158+ for (const node of hoisted.root.values()) {
159159+ collectNodes(node, '/node_modules');
160160+ }
161161+162162+ // process queue with concurrency limit using a simple semaphore pattern
163163+ let index = 0;
164164+ let completed = 0;
165165+ const total = queue.length;
166166+167167+ async function worker(): Promise<void> {
168168+ while (true) {
169169+ const i = index++;
170170+ if (i >= queue.length) {
171171+ break;
172172+ }
173173+174174+ const { node, basePath } = queue[i];
175175+ const packagePath = `${basePath}/${node.name}`;
176176+177177+ const extractedSize = await fetchTarballToVolume(node.tarball, packagePath, volume, exclude);
178178+179179+ // use extracted size if registry didn't provide unpackedSize (e.g., JSR packages)
180180+ if (node.unpackedSize === undefined) {
181181+ node.unpackedSize = extractedSize;
182182+ }
183183+184184+ completed++;
185185+ progress.emit({ type: 'progress', kind: 'fetch', current: completed, total, name: node.name });
186186+ }
187187+ }
188188+189189+ // start concurrent workers
190190+ const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker());
191191+ await Promise.all(workers);
192192+}
+139
src/npm/hoist.ts
···11+import type { HoistedNode, HoistedResult, ResolvedPackage } from './types';
22+33+/**
44+ * attempts to place a package at the root level.
55+ * returns true if placement succeeded, false if there's a conflict.
66+ *
77+ * a conflict occurs when:
88+ * - a different version of the same package is already at root
99+ *
1010+ * @param root the current root node_modules map
1111+ * @param pkg the package to place
1212+ * @returns true if placed at root, false if needs nesting
1313+ */
1414+function tryPlaceAtRoot(root: Map<string, HoistedNode>, pkg: ResolvedPackage): boolean {
1515+ const existing = root.get(pkg.name);
1616+1717+ if (!existing) {
1818+ // no conflict, place at root
1919+ root.set(pkg.name, {
2020+ name: pkg.name,
2121+ version: pkg.version,
2222+ tarball: pkg.tarball,
2323+ integrity: pkg.integrity,
2424+ unpackedSize: pkg.unpackedSize,
2525+ description: pkg.description,
2626+ license: pkg.license,
2727+ dependencyCount: pkg.dependencies.size,
2828+ nested: new Map(),
2929+ });
3030+ return true;
3131+ }
3232+3333+ // same version already at root - reuse it
3434+ if (existing.version === pkg.version) {
3535+ return true;
3636+ }
3737+3838+ // different version - conflict, needs nesting
3939+ return false;
4040+}
4141+4242+/**
4343+ * hoists dependencies as high as possible in the tree.
4444+ * follows npm's hoisting algorithm:
4545+ * 1. try to place each package at root
4646+ * 2. if conflict, nest it under its parent
4747+ *
4848+ * peer dependencies are handled by the resolver - they're added as regular
4949+ * dependencies of the package that requested them, so they naturally get
5050+ * hoisted to root if no conflict, or nested under the dependent if there's
5151+ * a version conflict. this ensures the bundler resolves peers correctly.
5252+ *
5353+ * @param roots the root packages from resolution
5454+ * @returns the hoisted node_modules structure
5555+ */
5656+export function hoist(roots: ResolvedPackage[]): HoistedResult {
5757+ const root = new Map<string, HoistedNode>();
5858+5959+ // track which packages we've visited to avoid infinite loops
6060+ const visited = new Set<string>();
6161+6262+ /**
6363+ * recursively process a package and its dependencies.
6464+ * returns the hoisted node for this package.
6565+ */
6666+ function processPackage(pkg: ResolvedPackage, parentNode: HoistedNode | null): HoistedNode | null {
6767+ const key = `${pkg.name}@${pkg.version}`;
6868+6969+ // skip if already processed
7070+ if (visited.has(key)) {
7171+ // return the existing node from root if it exists
7272+ return root.get(pkg.name) ?? null;
7373+ }
7474+ visited.add(key);
7575+7676+ // try to place at root first
7777+ const placedAtRoot = tryPlaceAtRoot(root, pkg);
7878+ let node: HoistedNode;
7979+8080+ if (placedAtRoot) {
8181+ node = root.get(pkg.name)!;
8282+ } else if (parentNode) {
8383+ // conflict at root, nest under parent
8484+ node = {
8585+ name: pkg.name,
8686+ version: pkg.version,
8787+ tarball: pkg.tarball,
8888+ integrity: pkg.integrity,
8989+ unpackedSize: pkg.unpackedSize,
9090+ description: pkg.description,
9191+ license: pkg.license,
9292+ dependencyCount: pkg.dependencies.size,
9393+ nested: new Map(),
9494+ };
9595+ parentNode.nested.set(pkg.name, node);
9696+ } else {
9797+ // this shouldn't happen for root packages
9898+ throw new Error(`cannot place root package ${pkg.name}@${pkg.version}`);
9999+ }
100100+101101+ // process dependencies
102102+ for (const dep of pkg.dependencies.values()) {
103103+ processPackage(dep, node);
104104+ }
105105+106106+ return node;
107107+ }
108108+109109+ // process all root packages
110110+ for (const rootPkg of roots) {
111111+ processPackage(rootPkg, null);
112112+ }
113113+114114+ return { root };
115115+}
116116+117117+/**
118118+ * converts a hoisted result to a flat list of paths.
119119+ * useful for debugging and testing.
120120+ *
121121+ * @param result the hoisted result
122122+ * @returns array of paths like ["node_modules/react", "node_modules/react/node_modules/scheduler"]
123123+ */
124124+export function hoistedToPaths(result: HoistedResult): string[] {
125125+ const paths: string[] = [];
126126+127127+ function walk(nodes: Map<string, HoistedNode>, prefix: string): void {
128128+ for (const [name, node] of nodes) {
129129+ const path = `${prefix}/${name}`;
130130+ paths.push(path);
131131+ if (node.nested.size > 0) {
132132+ walk(node.nested, `${path}/node_modules`);
133133+ }
134134+ }
135135+ }
136136+137137+ walk(result.root, 'node_modules');
138138+ return paths.sort();
139139+}
+105
src/npm/registry.ts
···11+import { FetchError, InvalidSpecifierError, PackageNotFoundError } from './errors';
22+import type { Packument, Registry } from './types';
33+44+const NPM_REGISTRY = 'https://registry.npmjs.org';
55+const JSR_REGISTRY = 'https://npm.jsr.io';
66+77+/**
88+ * cache for packuments to avoid refetching during resolution.
99+ * key format: "registry:name" (e.g., "npm:react" or "jsr:@luca/flag")
1010+ */
1111+const packumentCache = new Map<string, Packument>();
1212+1313+/**
1414+ * transforms a JSR package name to the npm-compatible format.
1515+ * `@scope/name` becomes `@jsr/scope__name`
1616+ *
1717+ * @param name the JSR package name (must be scoped)
1818+ * @returns the transformed npm-compatible name
1919+ */
2020+export function transformJsrName(name: string): string {
2121+ if (!name.startsWith('@')) {
2222+ throw new InvalidSpecifierError(name, 'JSR packages must be scoped');
2323+ }
2424+ // @scope/name -> @jsr/scope__name
2525+ const withoutAt = name.slice(1); // "scope/name"
2626+ const transformed = withoutAt.replace('/', '__'); // "scope__name"
2727+ return `@jsr/${transformed}`;
2828+}
2929+3030+/**
3131+ * reverses the JSR npm-compatible name back to the canonical format.
3232+ * `@jsr/scope__name` becomes `@scope/name`
3333+ *
3434+ * @param name the npm-compatible JSR package name
3535+ * @returns the canonical JSR package name
3636+ */
3737+export function reverseJsrName(name: string): string {
3838+ if (!name.startsWith('@jsr/')) {
3939+ throw new InvalidSpecifierError(name, 'not a JSR npm-compatible name');
4040+ }
4141+ // @jsr/scope__name -> @scope/name
4242+ const withoutPrefix = name.slice(5); // "scope__name"
4343+ const restored = withoutPrefix.replace('__', '/'); // "scope/name"
4444+ return `@${restored}`;
4545+}
4646+4747+/**
4848+ * fetches the packument (full package metadata) from a registry.
4949+ * uses the abbreviated format when possible for smaller payloads.
5050+ *
5151+ * @param name the package name (can be scoped like @scope/pkg)
5252+ * @param registry which registry to fetch from (defaults to 'npm')
5353+ * @returns the packument with all versions
5454+ * @throws if the package doesn't exist or network fails
5555+ */
5656+export async function fetchPackument(name: string, registry: Registry = 'npm'): Promise<Packument> {
5757+ const cacheKey = `${registry}:${name}`;
5858+ const cached = packumentCache.get(cacheKey);
5959+ if (cached) {
6060+ return cached;
6161+ }
6262+6363+ let registryUrl: string;
6464+ let fetchName: string;
6565+6666+ if (registry === 'jsr') {
6767+ registryUrl = JSR_REGISTRY;
6868+ fetchName = transformJsrName(name);
6969+ } else {
7070+ registryUrl = NPM_REGISTRY;
7171+ fetchName = name;
7272+ }
7373+7474+ const encodedName = fetchName.startsWith('@')
7575+ ? `@${encodeURIComponent(fetchName.slice(1))}`
7676+ : encodeURIComponent(fetchName);
7777+7878+ const url = `${registryUrl}/${encodedName}`;
7979+8080+ const response = await fetch(url, {
8181+ headers: {
8282+ // request abbreviated format (corgi) for smaller payloads
8383+ Accept: 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8',
8484+ },
8585+ });
8686+8787+ if (!response.ok) {
8888+ if (response.status === 404) {
8989+ throw new PackageNotFoundError(name, registry);
9090+ }
9191+ throw new FetchError(url, response.status, response.statusText);
9292+ }
9393+9494+ const packument = (await response.json()) as Packument;
9595+ packumentCache.set(cacheKey, packument);
9696+ return packument;
9797+}
9898+9999+/**
100100+ * clears the packument cache.
101101+ * useful for testing or when you want fresh data.
102102+ */
103103+export function clearPackumentCache(): void {
104104+ packumentCache.clear();
105105+}
+303
src/npm/resolve.test.ts
···11+import { describe, expect, it } from 'vitest';
22+33+import { hoist, hoistedToPaths } from './hoist';
44+import { reverseJsrName, transformJsrName } from './registry';
55+import { parseSpecifier, pickVersion, resolve } from './resolve';
66+import type { PackageManifest } from './types';
77+88+describe('parseSpecifier', () => {
99+ it('parses bare package name', () => {
1010+ expect(parseSpecifier('react')).toEqual({ name: 'react', range: 'latest', registry: 'npm' });
1111+ });
1212+1313+ it('parses package with version', () => {
1414+ expect(parseSpecifier('react@18.2.0')).toEqual({
1515+ name: 'react',
1616+ range: '18.2.0',
1717+ registry: 'npm',
1818+ });
1919+ });
2020+2121+ it('parses package with range', () => {
2222+ expect(parseSpecifier('react@^18.0.0')).toEqual({
2323+ name: 'react',
2424+ range: '^18.0.0',
2525+ registry: 'npm',
2626+ });
2727+ });
2828+2929+ it('parses scoped package', () => {
3030+ expect(parseSpecifier('@babel/core')).toEqual({
3131+ name: '@babel/core',
3232+ range: 'latest',
3333+ registry: 'npm',
3434+ });
3535+ });
3636+3737+ it('parses scoped package with version', () => {
3838+ expect(parseSpecifier('@babel/core@7.23.0')).toEqual({
3939+ name: '@babel/core',
4040+ range: '7.23.0',
4141+ registry: 'npm',
4242+ });
4343+ });
4444+4545+ it('parses scoped package with range', () => {
4646+ expect(parseSpecifier('@types/node@^20.0.0')).toEqual({
4747+ name: '@types/node',
4848+ range: '^20.0.0',
4949+ registry: 'npm',
5050+ });
5151+ });
5252+5353+ it('parses jsr package', () => {
5454+ expect(parseSpecifier('jsr:@luca/flag')).toEqual({
5555+ name: '@luca/flag',
5656+ range: 'latest',
5757+ registry: 'jsr',
5858+ });
5959+ });
6060+6161+ it('parses jsr package with version', () => {
6262+ expect(parseSpecifier('jsr:@luca/flag@1.0.0')).toEqual({
6363+ name: '@luca/flag',
6464+ range: '1.0.0',
6565+ registry: 'jsr',
6666+ });
6767+ });
6868+6969+ it('parses jsr package with range', () => {
7070+ expect(parseSpecifier('jsr:@std/path@^1.0.0')).toEqual({
7171+ name: '@std/path',
7272+ range: '^1.0.0',
7373+ registry: 'jsr',
7474+ });
7575+ });
7676+7777+ it('throws for unscoped jsr package', () => {
7878+ expect(() => parseSpecifier('jsr:flag')).toThrow('JSR packages must be scoped');
7979+ });
8080+8181+ it('parses npm: prefix as noop', () => {
8282+ expect(parseSpecifier('npm:react')).toEqual({
8383+ name: 'react',
8484+ range: 'latest',
8585+ registry: 'npm',
8686+ });
8787+ });
8888+8989+ it('parses npm: prefix with version', () => {
9090+ expect(parseSpecifier('npm:react@18.2.0')).toEqual({
9191+ name: 'react',
9292+ range: '18.2.0',
9393+ registry: 'npm',
9494+ });
9595+ });
9696+9797+ it('parses npm: prefix with scoped package', () => {
9898+ expect(parseSpecifier('npm:@babel/core@^7.0.0')).toEqual({
9999+ name: '@babel/core',
100100+ range: '^7.0.0',
101101+ registry: 'npm',
102102+ });
103103+ });
104104+});
105105+106106+describe('transformJsrName', () => {
107107+ it('transforms scoped package name', () => {
108108+ expect(transformJsrName('@luca/flag')).toBe('@jsr/luca__flag');
109109+ });
110110+111111+ it('transforms std package name', () => {
112112+ expect(transformJsrName('@std/path')).toBe('@jsr/std__path');
113113+ });
114114+115115+ it('throws for unscoped package', () => {
116116+ expect(() => transformJsrName('flag')).toThrow('JSR packages must be scoped');
117117+ });
118118+});
119119+120120+describe('reverseJsrName', () => {
121121+ it('reverses npm-compatible JSR name', () => {
122122+ expect(reverseJsrName('@jsr/luca__flag')).toBe('@luca/flag');
123123+ });
124124+125125+ it('reverses std package name', () => {
126126+ expect(reverseJsrName('@jsr/std__internal')).toBe('@std/internal');
127127+ });
128128+129129+ it('throws for non-JSR name', () => {
130130+ expect(() => reverseJsrName('@babel/core')).toThrow('not a JSR npm-compatible name');
131131+ });
132132+});
133133+134134+describe('pickVersion', () => {
135135+ const mockVersions: Record<string, PackageManifest> = {
136136+ '1.0.0': {
137137+ name: 'test',
138138+ version: '1.0.0',
139139+ dist: { tarball: 'https://example.com/test-1.0.0.tgz' },
140140+ },
141141+ '1.1.0': {
142142+ name: 'test',
143143+ version: '1.1.0',
144144+ dist: { tarball: 'https://example.com/test-1.1.0.tgz' },
145145+ },
146146+ '2.0.0': {
147147+ name: 'test',
148148+ version: '2.0.0',
149149+ dist: { tarball: 'https://example.com/test-2.0.0.tgz' },
150150+ },
151151+ '2.1.0-beta.1': {
152152+ name: 'test',
153153+ version: '2.1.0-beta.1',
154154+ dist: { tarball: 'https://example.com/test-2.1.0-beta.1.tgz' },
155155+ },
156156+ };
157157+158158+ const distTags = { latest: '2.0.0', next: '2.1.0-beta.1' };
159159+160160+ it('resolves dist-tag', () => {
161161+ const result = pickVersion(mockVersions, distTags, 'latest');
162162+ expect(result?.version).toBe('2.0.0');
163163+ });
164164+165165+ it('resolves next dist-tag', () => {
166166+ const result = pickVersion(mockVersions, distTags, 'next');
167167+ expect(result?.version).toBe('2.1.0-beta.1');
168168+ });
169169+170170+ it('resolves exact version', () => {
171171+ const result = pickVersion(mockVersions, distTags, '1.0.0');
172172+ expect(result?.version).toBe('1.0.0');
173173+ });
174174+175175+ it('resolves caret range', () => {
176176+ const result = pickVersion(mockVersions, distTags, '^1.0.0');
177177+ expect(result?.version).toBe('1.1.0');
178178+ });
179179+180180+ it('resolves tilde range', () => {
181181+ const result = pickVersion(mockVersions, distTags, '~1.0.0');
182182+ expect(result?.version).toBe('1.0.0');
183183+ });
184184+185185+ it('returns null for unsatisfied range', () => {
186186+ const result = pickVersion(mockVersions, distTags, '^3.0.0');
187187+ expect(result).toBeNull();
188188+ });
189189+});
190190+191191+describe('resolve', () => {
192192+ it('resolves a simple package', async () => {
193193+ const result = await resolve(['is-odd@3.0.1']);
194194+195195+ expect(result.roots).toHaveLength(1);
196196+ expect(result.roots[0].name).toBe('is-odd');
197197+ expect(result.roots[0].version).toBe('3.0.1');
198198+199199+ // is-odd depends on is-number
200200+ expect(result.roots[0].dependencies.has('is-number')).toBe(true);
201201+ });
202202+203203+ it('resolves multiple packages', async () => {
204204+ const result = await resolve(['is-odd@3.0.1', 'is-even@1.0.0']);
205205+206206+ expect(result.roots).toHaveLength(2);
207207+ expect(result.roots[0].name).toBe('is-odd');
208208+ expect(result.roots[1].name).toBe('is-even');
209209+ });
210210+211211+ it('deduplicates shared dependencies', async () => {
212212+ // both is-odd and is-even depend on is-number
213213+ const result = await resolve(['is-odd@3.0.1', 'is-even@1.0.0']);
214214+215215+ // count unique packages
216216+ const isNumberVersions = new Set<string>();
217217+ for (const pkg of result.packages.values()) {
218218+ if (pkg.name === 'is-number') {
219219+ isNumberVersions.add(pkg.version);
220220+ }
221221+ }
222222+223223+ // should have is-number in the packages map
224224+ expect(isNumberVersions.size).toBeGreaterThan(0);
225225+ });
226226+227227+ it('resolves a JSR package', async () => {
228228+ const result = await resolve(['jsr:@luca/flag@1.0.1']);
229229+230230+ expect(result.roots).toHaveLength(1);
231231+ expect(result.roots[0].name).toBe('@luca/flag');
232232+ expect(result.roots[0].version).toBe('1.0.1');
233233+ expect(result.roots[0].tarball).toContain('npm.jsr.io');
234234+ });
235235+236236+ it('resolves a JSR package with JSR dependencies', async () => {
237237+ // @std/path@1.1.4 depends on @jsr/std__internal (reversed to @std/internal)
238238+ const result = await resolve(['jsr:@std/path@1.1.4']);
239239+240240+ expect(result.roots).toHaveLength(1);
241241+ expect(result.roots[0].name).toBe('@std/path');
242242+ expect(result.roots[0].version).toBe('1.1.4');
243243+244244+ // dependency is stored under the original name from the manifest
245245+ expect(result.roots[0].dependencies.has('@jsr/std__internal')).toBe(true);
246246+ const internal = result.roots[0].dependencies.get('@jsr/std__internal')!;
247247+ // but the resolved package uses the canonical name
248248+ expect(internal.name).toBe('@std/internal');
249249+ expect(internal.tarball).toContain('npm.jsr.io');
250250+ });
251251+252252+ it('auto-installs required peer dependencies', async () => {
253253+ // use-sync-external-store has react as a required peer
254254+ const result = await resolve(['use-sync-external-store@1.2.0']);
255255+256256+ // react should be added as a dependency of use-sync-external-store
257257+ const mainPkg = result.roots[0];
258258+ expect(mainPkg.dependencies.has('react')).toBe(true);
259259+260260+ // react should also be in the packages map
261261+ const hasReact = Array.from(result.packages.values()).some((p) => p.name === 'react');
262262+ expect(hasReact).toBe(true);
263263+ });
264264+265265+ it('skips optional peer dependencies', async () => {
266266+ // resolve something with optional peers and verify they're not installed
267267+ const result = await resolve(['use-sync-external-store@1.2.0']);
268268+269269+ // react should be there (required peer) as a dependency
270270+ const mainPkg = result.roots[0];
271271+ expect(mainPkg.dependencies.has('react')).toBe(true);
272272+ });
273273+274274+ it('respects installPeers: false option', async () => {
275275+ const result = await resolve(['use-sync-external-store@1.2.0'], { installPeers: false });
276276+277277+ // should only have the requested package
278278+ expect(result.roots).toHaveLength(1);
279279+ expect(result.roots[0].name).toBe('use-sync-external-store');
280280+ });
281281+});
282282+283283+describe('hoist', () => {
284284+ it('hoists simple dependencies to root', async () => {
285285+ const result = await resolve(['is-odd@3.0.1']);
286286+ const hoisted = hoist(result.roots);
287287+ const paths = hoistedToPaths(hoisted);
288288+289289+ // both is-odd and is-number should be at root
290290+ expect(paths).toContain('node_modules/is-odd');
291291+ expect(paths).toContain('node_modules/is-number');
292292+ });
293293+294294+ it('nests conflicting versions', async () => {
295295+ // this test would need packages with conflicting versions
296296+ // for now, just verify the basic structure works
297297+ const result = await resolve(['is-odd@3.0.1']);
298298+ const hoisted = hoist(result.roots);
299299+300300+ expect(hoisted.root.size).toBeGreaterThan(0);
301301+ expect(hoisted.root.has('is-odd')).toBe(true);
302302+ });
303303+});
+262
src/npm/resolve.ts
···11+import * as semver from 'semver';
22+33+import { InvalidSpecifierError, NoMatchingVersionError } from './errors';
44+import { progress } from './events';
55+import { fetchPackument, reverseJsrName } from './registry';
66+import type { PackageManifest, PackageSpecifier, Registry, ResolvedPackage, ResolutionResult } from './types';
77+88+/**
99+ * parses a package specifier string into name, range, and registry.
1010+ * handles scoped packages, JSR packages, and various formats:
1111+ * - "foo" -> { name: "foo", range: "latest", registry: "npm" }
1212+ * - "foo@^1.0.0" -> { name: "foo", range: "^1.0.0", registry: "npm" }
1313+ * - "@scope/foo@~2.0.0" -> { name: "@scope/foo", range: "~2.0.0", registry: "npm" }
1414+ * - "npm:foo@^1.0.0" -> { name: "foo", range: "^1.0.0", registry: "npm" }
1515+ * - "jsr:@luca/flag" -> { name: "@luca/flag", range: "latest", registry: "jsr" }
1616+ * - "jsr:@luca/flag@^1.0.0" -> { name: "@luca/flag", range: "^1.0.0", registry: "jsr" }
1717+ *
1818+ * @param spec the package specifier string
1919+ * @returns parsed specifier with name, range, and registry
2020+ */
2121+export function parseSpecifier(spec: string): PackageSpecifier {
2222+ let registry: Registry = 'npm';
2323+ let rest = spec;
2424+2525+ // check for registry prefixes
2626+ if (spec.startsWith('jsr:')) {
2727+ registry = 'jsr';
2828+ rest = spec.slice(4); // remove "jsr:"
2929+ } else if (spec.startsWith('npm:')) {
3030+ rest = spec.slice(4); // remove "npm:", registry already 'npm'
3131+ }
3232+3333+ // handle scoped packages: @scope/name or @scope/name@version
3434+ if (rest.startsWith('@')) {
3535+ const slashIdx = rest.indexOf('/');
3636+ if (slashIdx === -1) {
3737+ throw new InvalidSpecifierError(spec, 'scoped package missing slash');
3838+ }
3939+ const atIdx = rest.indexOf('@', slashIdx);
4040+ if (atIdx === -1) {
4141+ return { name: rest, range: 'latest', registry };
4242+ }
4343+ return { name: rest.slice(0, atIdx), range: rest.slice(atIdx + 1), registry };
4444+ }
4545+4646+ // JSR packages must be scoped
4747+ if (registry === 'jsr') {
4848+ throw new InvalidSpecifierError(spec, 'JSR packages must be scoped');
4949+ }
5050+5151+ // handle regular packages: name or name@version
5252+ const atIdx = rest.indexOf('@');
5353+ if (atIdx === -1) {
5454+ return { name: rest, range: 'latest', registry };
5555+ }
5656+ return { name: rest.slice(0, atIdx), range: rest.slice(atIdx + 1), registry };
5757+}
5858+5959+/**
6060+ * picks the best version from a packument that satisfies a range.
6161+ * follows npm's algorithm:
6262+ * 1. if range is a dist-tag, use that version
6363+ * 2. if range is a specific version, use that
6464+ * 3. otherwise, find highest version that satisfies the semver range
6565+ *
6666+ * @param versions available versions (version string -> manifest)
6767+ * @param distTags dist-tags mapping (e.g., { latest: "1.2.3" })
6868+ * @param range the version range to satisfy
6969+ * @returns the best matching manifest, or null if none match
7070+ */
7171+export function pickVersion(
7272+ versions: Record<string, PackageManifest>,
7373+ distTags: Record<string, string>,
7474+ range: string,
7575+): PackageManifest | null {
7676+ // check if range is a dist-tag
7777+ if (range in distTags) {
7878+ const taggedVersion = distTags[range];
7979+ return versions[taggedVersion] ?? null;
8080+ }
8181+8282+ // check if range is an exact version
8383+ if (versions[range]) {
8484+ return versions[range];
8585+ }
8686+8787+ // find highest version satisfying the range
8888+ const validVersions = Object.keys(versions)
8989+ .filter((v) => semver.satisfies(v, range))
9090+ .sort(semver.rcompare);
9191+9292+ if (validVersions.length === 0) {
9393+ return null;
9494+ }
9595+9696+ return versions[validVersions[0]];
9797+}
9898+9999+/**
100100+ * options for dependency resolution.
101101+ */
102102+export interface ResolveOptions {
103103+ /**
104104+ * whether to auto-install peer dependencies.
105105+ * when true, required (non-optional) peer dependencies are resolved automatically.
106106+ * @default true
107107+ */
108108+ installPeers?: boolean;
109109+}
110110+111111+/**
112112+ * context for tracking resolution state across recursive calls.
113113+ */
114114+interface ResolutionContext {
115115+ /** all resolved packages by "registry:name@version" key for deduping */
116116+ resolved: Map<string, ResolvedPackage>;
117117+ /** packages currently being resolved (for cycle detection) */
118118+ resolving: Set<string>;
119119+ /** resolution options */
120120+ options: Required<ResolveOptions>;
121121+}
122122+123123+/**
124124+ * resolves a single package and its dependencies recursively.
125125+ *
126126+ * @param name package name
127127+ * @param range version range to satisfy
128128+ * @param registry which registry to fetch from
129129+ * @param ctx resolution context for deduping and cycle detection
130130+ * @returns the resolved package tree
131131+ */
132132+async function resolvePackage(
133133+ name: string,
134134+ range: string,
135135+ registry: Registry,
136136+ ctx: ResolutionContext,
137137+): Promise<ResolvedPackage> {
138138+ const packument = await fetchPackument(name, registry);
139139+ const manifest = pickVersion(packument.versions, packument['dist-tags'], range);
140140+141141+ if (!manifest) {
142142+ throw new NoMatchingVersionError(name, range);
143143+ }
144144+145145+ progress.emit({ type: 'progress', kind: 'resolve', name, version: manifest.version });
146146+147147+ const key = `${registry}:${name}@${manifest.version}`;
148148+149149+ // check if already resolved (deduplication)
150150+ const existing = ctx.resolved.get(key);
151151+ if (existing) {
152152+ return existing;
153153+ }
154154+155155+ // cycle detection - if we're already resolving this, return a placeholder
156156+ // the actual dependencies will be filled in by the original resolution
157157+ if (ctx.resolving.has(key)) {
158158+ // create a minimal resolved package for the cycle
159159+ const cyclic: ResolvedPackage = {
160160+ name,
161161+ version: manifest.version,
162162+ tarball: manifest.dist.tarball,
163163+ integrity: manifest.dist.integrity,
164164+ dependencies: new Map(),
165165+ };
166166+ return cyclic;
167167+ }
168168+169169+ ctx.resolving.add(key);
170170+171171+ // create the resolved package
172172+ const resolved: ResolvedPackage = {
173173+ name,
174174+ version: manifest.version,
175175+ tarball: manifest.dist.tarball,
176176+ integrity: manifest.dist.integrity,
177177+ unpackedSize: manifest.dist.unpackedSize,
178178+ description: manifest.description,
179179+ license: manifest.license,
180180+ dependencies: new Map(),
181181+ };
182182+183183+ // register early so cycles can find it
184184+ ctx.resolved.set(key, resolved);
185185+186186+ // collect all dependencies to resolve (regular deps + peer deps)
187187+ const depsToResolve: Array<[string, string]> = [];
188188+189189+ // add regular dependencies
190190+ const deps = manifest.dependencies ?? {};
191191+ for (const [depName, depRange] of Object.entries(deps)) {
192192+ depsToResolve.push([depName, depRange]);
193193+ }
194194+195195+ // add peer dependencies as regular dependencies of this package
196196+ // this ensures they get hoisted correctly - placed at root if no conflict,
197197+ // or nested under this package if there's a version conflict
198198+ if (ctx.options.installPeers && manifest.peerDependencies) {
199199+ const peerMeta = manifest.peerDependenciesMeta ?? {};
200200+ for (const [peerName, peerRange] of Object.entries(manifest.peerDependencies)) {
201201+ const isOptional = peerMeta[peerName]?.optional === true;
202202+ if (!isOptional) {
203203+ // only add if not already in regular deps (regular deps take precedence)
204204+ if (!(peerName in deps)) {
205205+ depsToResolve.push([peerName, peerRange]);
206206+ }
207207+ }
208208+ }
209209+ }
210210+211211+ // resolve all dependencies in parallel
212212+ const resolvedDeps = await Promise.all(
213213+ depsToResolve.map(async ([depName, depRange]) => {
214214+ // when a JSR package depends on @jsr/*, reverse to canonical name and fetch from JSR
215215+ // otherwise use npm (even for @jsr/* from npm packages - that's what the author intended)
216216+ let resolvedName = depName;
217217+ let depRegistry: Registry = 'npm';
218218+ if (registry === 'jsr' && depName.startsWith('@jsr/')) {
219219+ resolvedName = reverseJsrName(depName);
220220+ depRegistry = 'jsr';
221221+ }
222222+ const dep = await resolvePackage(resolvedName, depRange, depRegistry, ctx);
223223+ return [depName, dep] as const;
224224+ }),
225225+ );
226226+227227+ for (const [depName, dep] of resolvedDeps) {
228228+ resolved.dependencies.set(depName, dep);
229229+ }
230230+231231+ ctx.resolving.delete(key);
232232+ return resolved;
233233+}
234234+235235+/**
236236+ * resolves one or more packages and all their dependencies.
237237+ * this is the main entry point for dependency resolution.
238238+ *
239239+ * @param specifiers package specifiers to resolve (e.g., ["react@^18.0.0", "jsr:@luca/flag"])
240240+ * @param options resolution options
241241+ * @returns the full resolution result with all packages
242242+ */
243243+export async function resolve(specifiers: string[], options: ResolveOptions = {}): Promise<ResolutionResult> {
244244+ const ctx: ResolutionContext = {
245245+ resolved: new Map(),
246246+ resolving: new Set(),
247247+ options: {
248248+ installPeers: options.installPeers ?? true,
249249+ },
250250+ };
251251+252252+ const parsedSpecs = specifiers.map(parseSpecifier);
253253+254254+ const roots = await Promise.all(
255255+ parsedSpecs.map(({ name, range, registry }) => resolvePackage(name, range, registry, ctx)),
256256+ );
257257+258258+ return {
259259+ roots,
260260+ packages: ctx.resolved,
261261+ };
262262+}
+321
src/npm/subpaths.ts
···11+import type { Volume } from 'memfs';
22+33+import type { PackageExports, PackageJson } from './types';
44+55+// #region types
66+77+/**
88+ * a discovered subpath entry from package.json exports.
99+ */
1010+export interface Subpath {
1111+ /** the subpath pattern (e.g., ".", "./utils", "./feature/*") */
1212+ subpath: string;
1313+ /** the resolved file path relative to package root */
1414+ target: string;
1515+ /** whether this is a wildcard-expanded entry */
1616+ isWildcard: boolean;
1717+}
1818+1919+/**
2020+ * result of discovering package subpaths.
2121+ */
2222+export interface DiscoveredSubpaths {
2323+ /** all available subpaths */
2424+ subpaths: Subpath[];
2525+ /** the default subpath to select (usually ".") */
2626+ defaultSubpath: string | null;
2727+}
2828+2929+// #endregion
3030+3131+// #region condition resolution
3232+3333+/**
3434+ * condition priority for ESM browser bundling.
3535+ * higher index = higher priority.
3636+ */
3737+const CONDITION_PRIORITY = ['default', 'module', 'import', 'browser'] as const;
3838+3939+/**
4040+ * resolves a conditional export to a file path.
4141+ * handles nested conditions and returns the best match for ESM browser.
4242+ */
4343+function resolveCondition(value: PackageExports): string | null {
4444+ if (value === null) {
4545+ return null;
4646+ }
4747+4848+ if (typeof value === 'string') {
4949+ return value;
5050+ }
5151+5252+ if (Array.isArray(value)) {
5353+ // array means "try in order", take first
5454+ for (const item of value) {
5555+ const resolved = resolveCondition(item);
5656+ if (resolved) {
5757+ return resolved;
5858+ }
5959+ }
6060+ return null;
6161+ }
6262+6363+ if (typeof value === 'object') {
6464+ // check if this is a conditions object or a subpath object
6565+ const keys = Object.keys(value);
6666+6767+ // if any key starts with '.', this is a subpath object, not conditions
6868+ if (keys.some((k) => k.startsWith('.'))) {
6969+ return null;
7070+ }
7171+7272+ // this is a conditions object, find best match
7373+ let bestMatch: string | null = null;
7474+ let bestPriority = -1;
7575+7676+ for (const [condition, target] of Object.entries(value)) {
7777+ const priority = CONDITION_PRIORITY.indexOf(condition as (typeof CONDITION_PRIORITY)[number]);
7878+7979+ if (priority > bestPriority) {
8080+ const resolved = resolveCondition(target as PackageExports);
8181+ if (resolved) {
8282+ bestMatch = resolved;
8383+ bestPriority = priority;
8484+ }
8585+ }
8686+ }
8787+8888+ return bestMatch;
8989+ }
9090+9191+ return null;
9292+}
9393+9494+// #endregion
9595+9696+// #region wildcard expansion
9797+9898+/**
9999+ * recursively lists all files in a directory.
100100+ */
101101+function listFilesRecursive(volume: Volume, dir: string): string[] {
102102+ const files: string[] = [];
103103+104104+ try {
105105+ const entries = volume.readdirSync(dir, { withFileTypes: true });
106106+ for (const entry of entries) {
107107+ const fullPath = `${dir}/${entry.name}`;
108108+ if (entry.isDirectory()) {
109109+ files.push(...listFilesRecursive(volume, fullPath));
110110+ } else if (entry.isFile()) {
111111+ files.push(fullPath);
112112+ }
113113+ }
114114+ } catch {
115115+ // directory doesn't exist or can't be read
116116+ }
117117+118118+ return files;
119119+}
120120+121121+/**
122122+ * expands a wildcard pattern against the volume files.
123123+ *
124124+ * @param subpath the subpath pattern with wildcard (e.g., "./*")
125125+ * @param target the target pattern (e.g., "./*.js")
126126+ * @param packagePath the package path in volume (e.g., "/node_modules/pkg")
127127+ * @param volume the volume to search in
128128+ * @returns expanded subpath entries
129129+ */
130130+function expandWildcard(subpath: string, target: string, packagePath: string, volume: Volume): Subpath[] {
131131+ const entries: Subpath[] = [];
132132+133133+ // extract the parts before and after the wildcard
134134+ const targetParts = target.split('*');
135135+ if (targetParts.length !== 2) {
136136+ // invalid pattern, skip
137137+ return entries;
138138+ }
139139+140140+ const [prefix, suffix] = targetParts;
141141+ const subpathParts = subpath.split('*');
142142+ if (subpathParts.length !== 2) {
143143+ return entries;
144144+ }
145145+146146+ const [subpathPrefix, subpathSuffix] = subpathParts;
147147+148148+ // normalize the prefix to match volume paths
149149+ // target like "./src/*.js" becomes "/node_modules/pkg/src"
150150+ const searchDir = `${packagePath}/${prefix.replace(/^\.\//, '').replace(/\/$/, '')}`;
151151+152152+ // list all files in the search directory
153153+ const allFiles = listFilesRecursive(volume, searchDir);
154154+155155+ for (const filePath of allFiles) {
156156+ // check if file matches the pattern
157157+ const relativePath = filePath.slice(searchDir.length + 1);
158158+159159+ if (suffix && !filePath.endsWith(suffix)) {
160160+ continue;
161161+ }
162162+163163+ // extract the wildcard match
164164+ const match = suffix ? relativePath.slice(0, relativePath.length - suffix.length) : relativePath;
165165+166166+ // construct the subpath
167167+ const expandedSubpath = `${subpathPrefix}${match}${subpathSuffix}`;
168168+169169+ // construct the relative target
170170+ const expandedTarget = `./${prefix.replace(/^\.\//, '')}${match}${suffix}`;
171171+172172+ entries.push({
173173+ subpath: expandedSubpath,
174174+ target: expandedTarget,
175175+ isWildcard: true,
176176+ });
177177+ }
178178+179179+ return entries;
180180+}
181181+182182+// #endregion
183183+184184+// #region main discovery
185185+186186+/**
187187+ * discovers all available subpaths from a package's exports field.
188188+ *
189189+ * @param packageJson the package.json content
190190+ * @param volume the volume containing package files
191191+ * @returns discovered subpaths with default selection
192192+ */
193193+export function discoverSubpaths(packageJson: PackageJson, volume: Volume): DiscoveredSubpaths {
194194+ const entries: Subpath[] = [];
195195+ const packagePath = `/node_modules/${packageJson.name}`;
196196+197197+ // check for exports field first (takes precedence)
198198+ if (packageJson.exports !== undefined) {
199199+ const exportsField = packageJson.exports;
200200+201201+ if (typeof exportsField === 'string') {
202202+ // simple string export: "exports": "./index.js"
203203+ entries.push({
204204+ subpath: '.',
205205+ target: exportsField,
206206+ isWildcard: false,
207207+ });
208208+ } else if (Array.isArray(exportsField)) {
209209+ // array export: "exports": ["./index.js", "./index.cjs"]
210210+ const resolved = resolveCondition(exportsField);
211211+ if (resolved) {
212212+ entries.push({
213213+ subpath: '.',
214214+ target: resolved,
215215+ isWildcard: false,
216216+ });
217217+ }
218218+ } else if (typeof exportsField === 'object' && exportsField !== null) {
219219+ // object export - could be conditions or subpaths
220220+ const keys = Object.keys(exportsField);
221221+ const hasSubpaths = keys.some((k) => k.startsWith('.'));
222222+223223+ if (hasSubpaths) {
224224+ // subpath exports
225225+ for (const [subpath, value] of Object.entries(exportsField)) {
226226+ if (!subpath.startsWith('.')) {
227227+ continue;
228228+ }
229229+230230+ if (subpath.includes('*')) {
231231+ // wildcard pattern
232232+ const target = resolveCondition(value as PackageExports);
233233+ if (target && target.includes('*')) {
234234+ const expanded = expandWildcard(subpath, target, packagePath, volume);
235235+ entries.push(...expanded);
236236+ }
237237+ } else {
238238+ // regular subpath
239239+ const target = resolveCondition(value as PackageExports);
240240+ if (target) {
241241+ entries.push({
242242+ subpath,
243243+ target,
244244+ isWildcard: false,
245245+ });
246246+ }
247247+ }
248248+ }
249249+ } else {
250250+ // top-level conditions (no subpaths means this is conditions for ".")
251251+ const target = resolveCondition(exportsField);
252252+ if (target) {
253253+ entries.push({
254254+ subpath: '.',
255255+ target,
256256+ isWildcard: false,
257257+ });
258258+ }
259259+ }
260260+ }
261261+ } else {
262262+ // fallback to legacy fields
263263+ // priority: module > main > index.js
264264+ let legacyMain = packageJson.module || packageJson.main;
265265+266266+ if (!legacyMain) {
267267+ // check if index.js exists
268268+ try {
269269+ volume.statSync(`${packagePath}/index.js`);
270270+ legacyMain = './index.js';
271271+ } catch {
272272+ // no index.js
273273+ }
274274+ }
275275+276276+ if (legacyMain) {
277277+ entries.push({
278278+ subpath: '.',
279279+ target: legacyMain.startsWith('.') ? legacyMain : `./${legacyMain}`,
280280+ isWildcard: false,
281281+ });
282282+ }
283283+ }
284284+285285+ // determine default subpath
286286+ let defaultSubpath: string | null = null;
287287+288288+ // prefer "." if it exists
289289+ const mainEntry = entries.find((e) => e.subpath === '.');
290290+ if (mainEntry) {
291291+ defaultSubpath = '.';
292292+ } else if (entries.length > 0) {
293293+ // otherwise, pick first alphabetically
294294+ entries.sort((a, b) => a.subpath.localeCompare(b.subpath));
295295+ defaultSubpath = entries[0].subpath;
296296+ }
297297+298298+ return {
299299+ subpaths: entries,
300300+ defaultSubpath,
301301+ };
302302+}
303303+304304+/**
305305+ * gets the import specifier for a subpath entry.
306306+ * this is what you'd write in an import statement.
307307+ *
308308+ * @param packageName the package name
309309+ * @param entry the subpath entry
310310+ * @returns the import specifier (e.g., "react", "react/jsx-runtime")
311311+ */
312312+export function getImportSpecifier(packageName: string, entry: Subpath): string {
313313+ if (entry.subpath === '.') {
314314+ return packageName;
315315+ }
316316+317317+ // "./foo" -> "package/foo"
318318+ return `${packageName}${entry.subpath.slice(1)}`;
319319+}
320320+321321+// #endregion
+140
src/npm/types.ts
···11+/**
22+ * the parsed package.json of the main package.
33+ * includes fields relevant for export discovery and display.
44+ */
55+export interface PackageJson {
66+ name: string;
77+ version: string;
88+ description?: string;
99+ license?: string;
1010+ main?: string;
1111+ module?: string;
1212+ browser?: string | Record<string, string | false>;
1313+ types?: string;
1414+ typings?: string;
1515+ exports?: PackageExports;
1616+ type?: 'module' | 'commonjs';
1717+}
1818+1919+/**
2020+ * package exports field - can be a string, array, object, or nested conditions.
2121+ * https://nodejs.org/api/packages.html#exports
2222+ */
2323+export type PackageExports = string | string[] | { [key: string]: PackageExports } | null;
2424+2525+/**
2626+ * npm registry packument - the full metadata for a package including all versions.
2727+ * fetched from registry.npmjs.org/{package-name}
2828+ */
2929+export interface Packument {
3030+ name: string;
3131+ 'dist-tags': Record<string, string>;
3232+ versions: Record<string, PackageManifest>;
3333+ time?: Record<string, string>;
3434+}
3535+3636+/**
3737+ * package manifest for a specific version.
3838+ * this is what you'd find in a package.json plus registry metadata.
3939+ */
4040+export interface PackageManifest {
4141+ name: string;
4242+ version: string;
4343+ description?: string;
4444+ license?: string;
4545+ main?: string;
4646+ module?: string;
4747+ exports?: PackageExports;
4848+ type?: 'module' | 'commonjs';
4949+ dependencies?: Record<string, string>;
5050+ devDependencies?: Record<string, string>;
5151+ peerDependencies?: Record<string, string>;
5252+ peerDependenciesMeta?: Record<string, { optional?: boolean }>;
5353+ optionalDependencies?: Record<string, string>;
5454+ dist: {
5555+ tarball: string;
5656+ integrity?: string;
5757+ shasum?: string;
5858+ /** total unpacked size in bytes */
5959+ unpackedSize?: number;
6060+ /** number of files in the tarball */
6161+ fileCount?: number;
6262+ };
6363+}
6464+6565+/**
6666+ * a resolved package with its dependencies.
6767+ * this is the output of the resolution step before hoisting.
6868+ */
6969+export interface ResolvedPackage {
7070+ name: string;
7171+ version: string;
7272+ /** the tarball URL for fetching */
7373+ tarball: string;
7474+ /** SRI integrity hash if available */
7575+ integrity?: string;
7676+ /** unpacked size in bytes (from registry) */
7777+ unpackedSize?: number;
7878+ /** package description */
7979+ description?: string;
8080+ /** license identifier */
8181+ license?: string;
8282+ /** resolved dependencies (name -> ResolvedPackage) */
8383+ dependencies: Map<string, ResolvedPackage>;
8484+}
8585+8686+/**
8787+ * supported package registries.
8888+ */
8989+export type Registry = 'npm' | 'jsr';
9090+9191+/**
9292+ * the input to the resolver - a package specifier.
9393+ * can be just a name (uses latest) or name@version/range.
9494+ */
9595+export interface PackageSpecifier {
9696+ name: string;
9797+ /** version, range, or dist-tag. defaults to 'latest' */
9898+ range: string;
9999+ /** which registry to fetch from. defaults to 'npm' */
100100+ registry: Registry;
101101+}
102102+103103+/**
104104+ * the full resolution result - a tree of resolved packages.
105105+ */
106106+export interface ResolutionResult {
107107+ /** the root package(s) that were requested */
108108+ roots: ResolvedPackage[];
109109+ /** all unique packages in the resolution (for deduping) */
110110+ packages: Map<string, ResolvedPackage>;
111111+}
112112+113113+/**
114114+ * a node in the hoisted node_modules structure.
115115+ * represents what should be written to node_modules/{name}
116116+ */
117117+export interface HoistedNode {
118118+ name: string;
119119+ version: string;
120120+ tarball: string;
121121+ integrity?: string;
122122+ /** unpacked size in bytes (from registry) */
123123+ unpackedSize?: number;
124124+ /** package description */
125125+ description?: string;
126126+ /** license identifier */
127127+ license?: string;
128128+ /** number of direct dependencies */
129129+ dependencyCount: number;
130130+ /** nested node_modules for this package (when hoisting fails) */
131131+ nested: Map<string, HoistedNode>;
132132+}
133133+134134+/**
135135+ * the result of hoisting - a flat(ish) node_modules structure.
136136+ */
137137+export interface HoistedResult {
138138+ /** top-level node_modules entries */
139139+ root: Map<string, HoistedNode>;
140140+}
+147
src/npm/worker-client.ts
···11+import * as v from 'valibot';
22+33+import type { BundleOptions, BundleResult } from './bundler';
44+import { progress } from './events';
55+import {
66+ workerResponseSchema,
77+ type InitOptions,
88+ type InitResult,
99+ type WorkerRequest,
1010+} from './worker-protocol';
1111+1212+export type { InitResult };
1313+1414+/**
1515+ * a session for working with a package.
1616+ * holds the worker and initialization result.
1717+ */
1818+export interface PackageSession extends InitResult {
1919+ /** the worker instance for this session */
2020+ worker: BundlerWorker;
2121+}
2222+2323+/**
2424+ * client for communicating with a bundler worker.
2525+ * each instance spawns a new worker, intended for one package.
2626+ */
2727+export class BundlerWorker {
2828+ private worker: Worker;
2929+ private nextId = 0;
3030+ private pending = new Map<number, PromiseWithResolvers<unknown>>();
3131+ private ready: Promise<void>;
3232+ private resolveReady!: () => void;
3333+3434+ constructor() {
3535+ this.ready = new Promise((resolve) => {
3636+ this.resolveReady = resolve;
3737+ });
3838+3939+ this.worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
4040+ this.worker.onmessage = this.handleMessage.bind(this);
4141+ this.worker.onerror = this.handleError.bind(this);
4242+ }
4343+4444+ private handleMessage(event: MessageEvent<unknown>): void {
4545+ // check for ready signal
4646+ if (event.data && typeof event.data === 'object' && 'type' in event.data && event.data.type === 'ready') {
4747+ console.log('[worker-client] received ready signal');
4848+ this.resolveReady();
4949+ return;
5050+ }
5151+5252+ const parsed = v.safeParse(workerResponseSchema, event.data);
5353+ if (!parsed.success) {
5454+ console.error('[worker-client] invalid response:', parsed.issues, event.data);
5555+ return;
5656+ }
5757+5858+ const response = parsed.output;
5959+6060+ // forward progress messages to global emitter
6161+ if (response.type === 'progress') {
6262+ progress.emit(response);
6363+ return;
6464+ }
6565+6666+ const deferred = this.pending.get(response.id);
6767+ if (!deferred) {
6868+ // response for a request we no longer care about (e.g., superseded bundle)
6969+ return;
7070+ }
7171+7272+ this.pending.delete(response.id);
7373+7474+ if (response.type === 'error') {
7575+ deferred.reject(new Error(response.error));
7676+ } else {
7777+ deferred.resolve(response.result);
7878+ }
7979+ }
8080+8181+ private handleError(event: ErrorEvent): void {
8282+ console.error('[worker-client] worker error:', event);
8383+ // reject all pending requests
8484+ for (const deferred of this.pending.values()) {
8585+ deferred.reject(new Error('Worker error'));
8686+ }
8787+ this.pending.clear();
8888+ }
8989+9090+ private async send<T>(message: WorkerRequest): Promise<T> {
9191+ // wait for worker to be ready before sending
9292+ await this.ready;
9393+9494+ const deferred = Promise.withResolvers<T>();
9595+ this.pending.set(message.id, deferred as PromiseWithResolvers<unknown>);
9696+ console.log('[worker-client] posting message:', message);
9797+ this.worker.postMessage(message);
9898+ return deferred.promise;
9999+ }
100100+101101+ /**
102102+ * initializes the worker with a package.
103103+ * only the first call does work; subsequent calls return cached result.
104104+ */
105105+ init(packageSpec: string, options?: InitOptions): Promise<InitResult> {
106106+ return this.send<InitResult>({ id: this.nextId++, type: 'init', packageSpec, options });
107107+ }
108108+109109+ /**
110110+ * bundles a subpath from the initialized package.
111111+ * uses "latest wins" - if called while a bundle is in progress,
112112+ * the previous pending request is superseded.
113113+ */
114114+ bundle(subpath: string, selectedExports: string[] | null, options?: BundleOptions): Promise<BundleResult> {
115115+ return this.send<BundleResult>({ id: this.nextId++, type: 'bundle', subpath, selectedExports, options });
116116+ }
117117+118118+ /**
119119+ * terminates the worker.
120120+ */
121121+ terminate(): void {
122122+ this.worker.terminate();
123123+ for (const deferred of this.pending.values()) {
124124+ deferred.reject(new DOMException('Worker terminated', 'AbortError'));
125125+ }
126126+ this.pending.clear();
127127+ }
128128+}
129129+130130+/**
131131+ * creates a new worker and initializes it with a package.
132132+ * if initialization fails, the worker is terminated.
133133+ *
134134+ * @param packageSpec package specifier (e.g., "react@^18.0.0")
135135+ * @param options initialization options
136136+ * @returns session containing the worker and init result
137137+ */
138138+export async function initPackage(packageSpec: string, options?: InitOptions): Promise<PackageSession> {
139139+ const worker = new BundlerWorker();
140140+ try {
141141+ const result = await worker.init(packageSpec, options);
142142+ return { worker, ...result };
143143+ } catch (error) {
144144+ worker.terminate();
145145+ throw error;
146146+ }
147147+}
···11+export { default as Root } from './dropdown/dropdown-root';
22+export { default as Trigger } from './dropdown/dropdown-trigger';
33+export { default as Listbox } from './dropdown/dropdown-listbox';
44+export { default as Option } from './dropdown/dropdown-option';
55+66+export type { DropdownRootProps } from './dropdown/dropdown-root';
77+export type { DropdownTriggerProps, DropdownSize, DropdownAppearance } from './dropdown/dropdown-trigger';
88+export type { DropdownListboxProps } from './dropdown/dropdown-listbox';
99+export type { DropdownOptionProps } from './dropdown/dropdown-option';
1010+export type { DropdownContextValue } from './dropdown/context';
+54
src/primitives/dropdown/context.tsx
···11+import { createContext, useContext, type Accessor } from 'solid-js';
22+33+import type { ActiveDescendantController } from '../lib/create-active-descendant';
44+55+// #region types
66+77+export interface DropdownContextValue {
88+ /** whether the listbox is open */
99+ open: Accessor<boolean>;
1010+ /** set the listbox open state */
1111+ setOpen: (open: boolean) => void;
1212+ /** the trigger element ref */
1313+ triggerRef: Accessor<HTMLElement | null>;
1414+ /** set the trigger element ref */
1515+ setTriggerRef: (el: HTMLElement | null) => void;
1616+ /** unique ID for the trigger */
1717+ triggerId: string;
1818+ /** unique ID for the listbox */
1919+ listboxId: string;
2020+ /** currently selected value */
2121+ selectedValue: Accessor<string | undefined>;
2222+ /** select an option by value */
2323+ selectOption: (value: string, label: string) => void;
2424+ /** active descendant controller for keyboard navigation */
2525+ activeDescendant: ActiveDescendantController;
2626+ /** the listbox element ref for scrolling */
2727+ listboxRef: Accessor<HTMLElement | null>;
2828+ /** set the listbox element ref */
2929+ setListboxRef: (el: HTMLElement | null) => void;
3030+ /** map from option ID to option value */
3131+ getOptionValue: (id: string) => string | undefined;
3232+ /** register option value for an ID */
3333+ registerOptionValue: (id: string, value: string) => void;
3434+ /** unregister option value for an ID */
3535+ unregisterOptionValue: (id: string) => void;
3636+}
3737+3838+// #endregion
3939+4040+// #region context
4141+4242+const DropdownContext = createContext<DropdownContextValue>();
4343+4444+export const DropdownProvider = DropdownContext.Provider;
4545+4646+export function useDropdownContext(): DropdownContextValue {
4747+ const ctx = useContext(DropdownContext);
4848+ if (!ctx) {
4949+ throw new Error('Dropdown components must be used within a Dropdown.Root');
5050+ }
5151+ return ctx;
5252+}
5353+5454+// #endregion
···11+export { default as Root } from './field/field-root';
22+33+export type { FieldRootProps, FieldSize, FieldOrientation } from './field/field-root';
44+export type { FieldContextValue, FieldValidationState } from './field/context';
55+export { useFieldContext } from './field/context';
+36
src/primitives/field/context.tsx
···11+import { createContext, useContext, type Accessor } from 'solid-js';
22+33+// #region types
44+55+export type FieldValidationState = 'error' | 'warning' | 'success' | 'none';
66+77+export interface FieldContextValue {
88+ /** unique ID for the control element */
99+ controlId: string;
1010+ /** unique ID for the validation message element */
1111+ validationMessageId: string;
1212+ /** unique ID for the hint element */
1313+ hintId: string;
1414+ /** whether the field is required */
1515+ required: Accessor<boolean>;
1616+ /** current validation state */
1717+ validationState: Accessor<FieldValidationState>;
1818+}
1919+2020+// #endregion
2121+2222+// #region context
2323+2424+const FieldContext = createContext<FieldContextValue>();
2525+2626+export const FieldProvider = FieldContext.Provider;
2727+2828+/**
2929+ * returns field context if inside a Field, undefined otherwise.
3030+ * use this in form controls to integrate with Field.
3131+ */
3232+export function useFieldContext(): FieldContextValue | undefined {
3333+ return useContext(FieldContext);
3434+}
3535+3636+// #endregion
···11+export { default as Item } from './menu/menu-item';
22+export { default as List } from './menu/menu-list';
33+export { default as Popover } from './menu/menu-popover';
44+export { default as Root } from './menu/menu-root';
55+export { default as Trigger } from './menu/menu-trigger';
66+77+export type { MenuItemProps } from './menu/menu-item';
88+export type { MenuListProps } from './menu/menu-list';
99+export type { MenuPopoverProps } from './menu/menu-popover';
1010+export type { MenuRootProps } from './menu/menu-root';
1111+export type { MenuTriggerChildProps, MenuTriggerProps } from './menu/menu-trigger';
1212+export type { MenuContextValue, MenuListContextValue } from './menu/context';
+66
src/primitives/menu/context.tsx
···11+import type { Placement } from '@floating-ui/dom';
22+import { createContext, useContext, type Accessor } from 'solid-js';
33+44+import type { RovingTabindexController } from '../lib/create-roving-tabindex';
55+66+// #region types
77+88+export interface MenuContextValue {
99+ /** whether the menu is open */
1010+ open: Accessor<boolean>;
1111+ /** set the menu open state */
1212+ setOpen: (open: boolean) => void;
1313+ /** the trigger element ref */
1414+ triggerRef: Accessor<HTMLElement | null>;
1515+ /** set the trigger element ref */
1616+ setTriggerRef: (el: HTMLElement | null) => void;
1717+ /** unique ID for the trigger */
1818+ triggerId: string;
1919+ /** unique ID for the menu */
2020+ menuId: string;
2121+ /** placement for the popover */
2222+ placement: () => Placement;
2323+ /** roving tabindex controller for keyboard navigation */
2424+ rovingTabindex: RovingTabindexController;
2525+ /** the popover element ref for scrolling */
2626+ popoverRef: Accessor<HTMLElement | null>;
2727+ /** set the popover element ref */
2828+ setPopoverRef: (el: HTMLElement | null) => void;
2929+}
3030+3131+export interface MenuListContextValue {
3232+ /** whether menu items should reserve space for checkmarks */
3333+ hasCheckmarks: boolean;
3434+ /** whether menu items should reserve space for icons */
3535+ hasIcons: boolean;
3636+}
3737+3838+// #endregion
3939+4040+// #region menu context
4141+4242+const MenuContext = createContext<MenuContextValue>();
4343+4444+export const MenuProvider = MenuContext.Provider;
4545+4646+export function useMenuContext(): MenuContextValue {
4747+ const ctx = useContext(MenuContext);
4848+ if (!ctx) {
4949+ throw new Error('Menu components must be used within a Menu.Root');
5050+ }
5151+ return ctx;
5252+}
5353+5454+// #endregion
5555+5656+// #region menu list context
5757+5858+const MenuListContext = createContext<MenuListContextValue>();
5959+6060+export const MenuListProvider = MenuListContext.Provider;
6161+6262+export function useMenuListContext(): MenuListContextValue | undefined {
6363+ return useContext(MenuListContext);
6464+}
6565+6666+// #endregion