diff --git a/CHANGELOG.md b/CHANGELOG.md index 92fc3ea..cd4d75d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Support multiple entry points for a single self-hosted dependency via the new `entries` map on `DependencyConfig`. All subpaths of a workspace package (e.g. a barrel `.` and a lazy `./manifest`) are built together in one code-split tsdown build, so modules shared between entries are emitted once as a shared chunk and referenced by every entry — a single runtime instance, eliminating the singleton/identity hazard of bundling each subpath separately ([#74](https://github.com/studiometa/playground/pull/74)) + ## v0.3.11 - 2026.08.01 ### Fixed diff --git a/packages/demo/lib/manifest.ts b/packages/demo/lib/manifest.ts new file mode 100644 index 0000000..e671b97 --- /dev/null +++ b/packages/demo/lib/manifest.ts @@ -0,0 +1,8 @@ +// A second entry point that lazily loads the shared `greeter` module. Together +// with the barrel (`index.ts`), which imports the same module statically, this +// exercises the multi-entry code-splitting path: `greeter` must be emitted once +// as a shared chunk referenced by both `index.js` and `manifest.js`. +export async function greetLazily(name: string): Promise { + const { greet } = await import('./greeter.js'); + return greet(name, { greeting: 'Lazy hello' }); +} diff --git a/packages/demo/meta.config.js b/packages/demo/meta.config.js index a4a770b..4aabeac 100644 --- a/packages/demo/meta.config.js +++ b/packages/demo/meta.config.js @@ -20,7 +20,10 @@ export default defineWebpackConfig({ { specifier: 'demo-lib', source: './lib/**/*.ts', - entry: './lib/index.ts', + entries: { + '.': './lib/index.ts', + './manifest': './lib/manifest.ts', + }, }, ], loaders: { diff --git a/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.test.ts b/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.test.ts index 0857815..7f1cde9 100644 --- a/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.test.ts +++ b/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, posix } from 'node:path'; import { describe, it, expect } from 'vitest'; import { PlaygroundDependenciesPlugin } from './PlaygroundDependenciesPlugin.js'; import type { ResolvedDependency } from '../utils/resolve-dependencies.js'; @@ -129,6 +132,43 @@ describe('PlaygroundDependenciesPlugin', () => { expect(importMap.deepmerge).toBe('https://esm.sh/deepmerge'); }); + it('prefixes every subpath of a multi-entry dependency', () => { + const deps: ResolvedDependency[] = [ + { + specifier: '@studiometa/ui', + importMapValue: '/static/deps/@studiometa/ui/index.js', + type: 'bundle', + entries: [ + { + subpath: '.', + specifier: '@studiometa/ui', + name: 'index', + source: '../ui/index.ts', + importMapValue: '/static/deps/@studiometa/ui/index.js', + }, + { + subpath: './manifest', + specifier: '@studiometa/ui/manifest', + name: 'manifest', + source: '../ui/manifest.ts', + importMapValue: '/static/deps/@studiometa/ui/manifest.js', + }, + ], + }, + ]; + const importMap = { + '@studiometa/ui': '/static/deps/@studiometa/ui/index.js', + '@studiometa/ui/manifest': '/static/deps/@studiometa/ui/manifest.js', + }; + + applyAndGetImportMap(deps, importMap, '/play'); + + expect(importMap['@studiometa/ui']).toBe('/play/static/deps/@studiometa/ui/index.js'); + expect(importMap['@studiometa/ui/manifest']).toBe( + '/play/static/deps/@studiometa/ui/manifest.js', + ); + }); + it('infers publicPath from webpack output.publicPath', () => { const deps: ResolvedDependency[] = [ { @@ -437,4 +477,118 @@ describe('PlaygroundDependenciesPlugin', () => { expect(emitted.has(headersDtsPath.replace(/^\//, ''))).toBe(true); }); }); + + describe('multi-entry singleton (real tsdown build)', () => { + /** + * Build a fake compilation that records every emitted asset into a + * `path -> code` map, throwing on a duplicate path (mirrors webpack's + * seal-time conflict guard). + */ + function makeFakeCompilation(emitted: Map) { + return { + compiler: { + webpack: { + sources: { + RawSource: class { + value: string; + constructor(value: string) { + this.value = value; + } + source() { + return this.value; + } + }, + }, + }, + }, + emitAsset(assetPath: string, source: { value: string }) { + if (emitted.has(assetPath)) { + throw new Error(`Conflict: multiple assets emit to the same filename ${assetPath}`); + } + emitted.set(assetPath, source.value); + }, + }; + } + + it('emits shared modules ONCE and references the same chunk from every entry', async () => { + // A workspace package with a barrel (static import) and a manifest + // (dynamic import) that both use the same shared module — the exact + // singleton hazard multi-entry builds are meant to eliminate. + const dir = mkdtempSync(join(tmpdir(), 'playground-multi-entry-')); + try { + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync( + join(dir, 'src/shared.ts'), + // A distinctive marker proves where the class body actually lives. + 'export class Shared {\n greet() {\n return "SINGLETON_MARKER";\n }\n}\n', + ); + writeFileSync( + join(dir, 'src/index.ts'), + "import { Shared } from './shared.js';\nexport { Shared };\nexport const fromBarrel = new Shared();\n", + ); + writeFileSync( + join(dir, 'src/manifest.ts'), + "export async function load() {\n const { Shared } = await import('./shared.js');\n return new Shared();\n}\n", + ); + + const dep: ResolvedDependency = { + specifier: '@test/pkg', + importMapValue: '/static/deps/@test/pkg/index.js', + type: 'bundle', + entries: [ + { + subpath: '.', + specifier: '@test/pkg', + name: 'index', + source: './src/index.ts', + importMapValue: '/static/deps/@test/pkg/index.js', + }, + { + subpath: './manifest', + specifier: '@test/pkg/manifest', + name: 'manifest', + source: './src/manifest.ts', + importMapValue: '/static/deps/@test/pkg/manifest.js', + }, + ], + }; + + const p = new PlaygroundDependenciesPlugin([dep], dir); + const emitted = new Map(); + await (p as any).processMultiEntryBundle(makeFakeCompilation(emitted), dep); + + const base = 'static/deps/@test/pkg'; + const indexPath = posix.join(base, 'index.js'); + const manifestPath = posix.join(base, 'manifest.js'); + + // Both named entries are emitted at their subpath filenames. + expect(emitted.has(indexPath)).toBe(true); + expect(emitted.has(manifestPath)).toBe(true); + + // Exactly ONE shared JS chunk (not a per-entry duplicate). + const sharedJsChunks = [...emitted.keys()].filter( + (path) => + path.startsWith(base) && + path.endsWith('.js') && + path !== indexPath && + path !== manifestPath, + ); + expect(sharedJsChunks).toHaveLength(1); + const sharedChunkPath = sharedJsChunks[0]; + const sharedChunkName = sharedChunkPath.slice(base.length + 1); + + // The class body lives in the shared chunk, NOT inlined in either entry. + expect(emitted.get(sharedChunkPath)).toContain('SINGLETON_MARKER'); + expect(emitted.get(indexPath)).not.toContain('SINGLETON_MARKER'); + expect(emitted.get(manifestPath)).not.toContain('SINGLETON_MARKER'); + + // Both entries import that same shared chunk as a relative sibling, so + // every consumer resolves the one-and-only module instance. + expect(emitted.get(indexPath)).toContain(`./${sharedChunkName}`); + expect(emitted.get(manifestPath)).toContain(`./${sharedChunkName}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); + }); }); diff --git a/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.ts b/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.ts index 9e59e0e..1249fcb 100644 --- a/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.ts +++ b/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.ts @@ -13,6 +13,13 @@ import { resolvePublicPath } from '../utils/resolve-public-path.js'; * when the dependency code-splits via dynamic `import()`, the extra chunks are * emitted alongside under their own content-hashed filenames. Works with both * npm packages and local TypeScript sources. + * + * A dependency with `entries` (multiple export subpaths of one workspace + * package) is instead built in a single code-split tsdown build: every entry + * is emitted at `static/deps//.js` and the modules shared + * between entries become one shared chunk referenced by all of them — a single + * runtime instance, avoiding the singleton/identity hazard of building each + * subpath as its own bundle. See `processMultiEntryBundle`. */ export class PlaygroundDependenciesPlugin { dependencies: ResolvedDependency[]; @@ -56,18 +63,33 @@ export class PlaygroundDependenciesPlugin { // mutating it here is picked up when HtmlWebpackPlugin renders templates. if (publicPath && this.importMap) { for (const dep of this.dependencies) { - const currentValue = this.importMap[dep.specifier]; - if (currentValue && !currentValue.startsWith('http')) { - this.importMap[dep.specifier] = publicPath + currentValue; + // A multi-entry dependency contributes one import-map key per entry + // subpath; single-entry ones contribute just their own specifier. + const specifiers = dep.entries?.length + ? dep.entries.map((entry) => entry.specifier) + : [dep.specifier]; + for (const specifier of specifiers) { + const currentValue = this.importMap[specifier]; + if (currentValue && !currentValue.startsWith('http')) { + this.importMap[specifier] = publicPath + currentValue; + } } } } // Watch local source files so webpack rebuilds when they change for (const dep of this.dependencies) { - if (dep.type === 'bundle' && dep.source && this.isLocalSource(dep.source)) { - const resolvedPattern = resolve(this.configDir, dep.source); - const isGlob = dep.source.includes('*'); + if (dep.type !== 'bundle') continue; + + // Multi-entry: watch each entry source file (and the optional glob source). + const watchSources = [ + ...(dep.entries?.map((entry) => entry.source) ?? []), + ...(dep.source ? [dep.source] : []), + ]; + for (const watchSource of watchSources) { + if (!this.isLocalSource(watchSource)) continue; + const resolvedPattern = resolve(this.configDir, watchSource); + const isGlob = watchSource.includes('*'); const sourceFiles = isGlob ? glob.globSync(resolvedPattern) : [resolvedPattern]; for (const file of sourceFiles) { compilation.fileDependencies.add(file); @@ -85,14 +107,27 @@ export class PlaygroundDependenciesPlugin { const headerEntries: Array<{ jsPath: string; dtsPath: string }> = []; for (const dep of this.dependencies) { - if (dep.type === 'bundle') { - await this.processBundle(compilation, dep); + if (dep.type !== 'bundle') continue; - headerEntries.push({ - jsPath: `${publicPath}/static/deps/${dep.specifier}/index.js`, - dtsPath: `${publicPath}/static/deps/${dep.specifier}/index.d.ts`, - }); + if (dep.entries?.length) { + // Multi-entry: one code-split build, one `_headers` line per entry. + await this.processMultiEntryBundle(compilation, dep); + + for (const entry of dep.entries) { + headerEntries.push({ + jsPath: `${publicPath}/static/deps/${dep.specifier}/${entry.name}.js`, + dtsPath: `${publicPath}/static/deps/${dep.specifier}/${entry.name}.d.ts`, + }); + } + continue; } + + await this.processBundle(compilation, dep); + + headerEntries.push({ + jsPath: `${publicPath}/static/deps/${dep.specifier}/index.js`, + dtsPath: `${publicPath}/static/deps/${dep.specifier}/index.d.ts`, + }); } // Emit _headers file (Cloudflare Pages format) for x-typescript-types @@ -188,6 +223,80 @@ export class PlaygroundDependenciesPlugin { } } + /** + * Bundle every entry point of a multi-entry dependency in a **single** + * tsdown build so that modules shared between entries become a single shared + * chunk instead of being duplicated per entry. + * + * This is the load-bearing property: when a workspace package exposes several + * entry points (e.g. a barrel `.` and a lazy `./manifest`), building each as + * its own bundle duplicates the shared component classes across bundles, + * producing distinct module instances — a singleton/identity hazard for + * class-keyed registries and `instanceof` checks. Building them together lets + * rolldown hoist those shared modules into one chunk referenced by every + * entry → a single instance. + * + * Each entry is emitted verbatim under the base `static/deps//` + * directory using its rolldown filename (`.js`, where `.` → `index`), + * and shared chunks keep their content-hashed names alongside. Because every + * chunk keeps the exact filename rolldown assigned, the entries' relative + * `import`/`import()` of the shared chunks resolve unchanged — no rewriting. + * + * @private + */ + private async processMultiEntryBundle(compilation: any, dep: ResolvedDependency) { + let tsdown: typeof import('tsdown'); + try { + tsdown = await import('tsdown'); + } catch { + console.warn( + `[playground] tsdown not found, skipping processing for "${dep.specifier}". ` + + 'Install it as a devDependency to enable this feature.', + ); + return; + } + + const entries = dep.entries ?? []; + if (entries.length === 0) return; + + // rolldown entry object: name → resolved source file. Names drive the + // emitted `.js` filenames (see `resolve-dependencies.ts`). + const entry: Record = {}; + for (const item of entries) { + entry[item.name] = resolve(this.configDir, item.source); + } + + // Externalize every import-map specifier except this package's own entry + // specifiers, so the shared runtime deps (e.g. js-toolkit) stay external + // while the package's own modules are bundled and shared across entries. + const ownSpecifiers = new Set([ + dep.specifier, + ...entries.map((item) => item.specifier), + ]); + const external = this.importMapKeys.filter((key) => !ownSpecifiers.has(key)); + + const outputBase = `static/deps/${dep.specifier}`; + + const buildResults = await tsdown.build({ + entry, + format: 'esm', + dts: true, + outDir: '/tmp', // unused with write: false + clean: false, + platform: 'browser', + target: 'es2020', + config: false, + write: false, + logLevel: 'silent', + external, + }); + + // Emit every chunk verbatim: entry chunks are already named `.js` + // (via the rolldown entry object) and shared chunks keep their hashed + // names, so relative imports between them resolve inside `outputBase`. + this.emitBundleChunks(compilation, buildResults, outputBase, false); + } + /** * Emit every chunk of a tsdown build result as a webpack asset under * `outputBase`. @@ -210,18 +319,28 @@ export class PlaygroundDependenciesPlugin { * `Conflict: Multiple assets emit different content to the same filename * static/deps//index.js`. * + * When `pinEntry` is `false` (multi-entry builds) every chunk — entries + * included — is emitted under its own rolldown filename. Multi-entry builds + * name their entries explicitly (`index.js`, `manifest.js`, …) via the + * rolldown entry object, so no pinning is needed and every relative import + * between entries and shared chunks resolves as emitted. + * * @private */ private emitBundleChunks( compilation: any, - buildResults: Array<{ chunks: Array<{ fileName: string; code?: string; isEntry?: boolean }> }>, + buildResults: Array<{ + chunks: Array<{ fileName: string; code?: string; isEntry?: boolean }>; + }>, outputBase: string, + pinEntry = true, ) { for (const buildResult of buildResults) { for (const chunk of buildResult.chunks) { if ('code' in chunk && typeof chunk.code === 'string') { const isDts = chunk.fileName.endsWith('.d.ts'); - const fileName = chunk.isEntry ? (isDts ? 'index.d.ts' : 'index.js') : chunk.fileName; + const fileName = + pinEntry && chunk.isEntry ? (isDts ? 'index.d.ts' : 'index.js') : chunk.fileName; const assetPath = posix.join(outputBase, fileName); compilation.emitAsset( assetPath, diff --git a/packages/playground/src/lib/utils/resolve-dependencies.test.ts b/packages/playground/src/lib/utils/resolve-dependencies.test.ts index ebcf635..a3f569b 100644 --- a/packages/playground/src/lib/utils/resolve-dependencies.test.ts +++ b/packages/playground/src/lib/utils/resolve-dependencies.test.ts @@ -426,6 +426,112 @@ describe('resolveDependencies', () => { }); }); + describe('multi-entry dependencies', () => { + it('resolves one import-map entry per subpath and a single self-hosted build', () => { + const result = resolveDependencies([ + { + specifier: '@studiometa/ui', + source: '../ui/**/*.ts', + entries: { + '.': '../ui/index.ts', + './manifest': '../ui/manifest.ts', + }, + }, + ]); + + // The barrel keeps the historical index.js filename. + expect(result.importMap).toEqual({ + '@studiometa/ui': '/static/deps/@studiometa/ui/index.js', + '@studiometa/ui/manifest': '/static/deps/@studiometa/ui/manifest.js', + }); + + // One self-hosted entry → one shared tsdown build. + expect(result.selfHosted).toHaveLength(1); + const dep = result.selfHosted[0]; + expect(dep).toMatchObject({ + specifier: '@studiometa/ui', + type: 'bundle', + source: '../ui/**/*.ts', + importMapValue: '/static/deps/@studiometa/ui/index.js', + }); + expect(dep.entries).toEqual([ + { + subpath: '.', + specifier: '@studiometa/ui', + name: 'index', + source: '../ui/index.ts', + importMapValue: '/static/deps/@studiometa/ui/index.js', + }, + { + subpath: './manifest', + specifier: '@studiometa/ui/manifest', + name: 'manifest', + source: '../ui/manifest.ts', + importMapValue: '/static/deps/@studiometa/ui/manifest.js', + }, + ]); + }); + + it('supports side-effect subpaths (e.g. autoload) alongside a barrel', () => { + const result = resolveDependencies([ + { + specifier: '@studiometa/ui-autoload', + entries: { + '.': '../ui-autoload/index.ts', + './ui': '../ui-autoload/ui.ts', + './ui-mapbox': '../ui-autoload/ui-mapbox.ts', + }, + }, + ]); + + expect(result.importMap).toEqual({ + '@studiometa/ui-autoload': '/static/deps/@studiometa/ui-autoload/index.js', + '@studiometa/ui-autoload/ui': '/static/deps/@studiometa/ui-autoload/ui.js', + '@studiometa/ui-autoload/ui-mapbox': '/static/deps/@studiometa/ui-autoload/ui-mapbox.js', + }); + expect(result.selfHosted).toHaveLength(1); + expect(result.selfHosted[0].entries).toHaveLength(3); + }); + + it('skips non-local entry sources with a warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = resolveDependencies([ + { + specifier: '@studiometa/ui', + entries: { + '.': '../ui/index.ts', + './manifest': '@studiometa/ui/manifest', // bare npm — unsupported + }, + }, + ]); + + expect(result.importMap).toEqual({ + '@studiometa/ui': '/static/deps/@studiometa/ui/index.js', + }); + expect(result.selfHosted[0].entries).toHaveLength(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('non-local source')); + + warn.mockRestore(); + }); + + it('produces no self-hosted build when every entry is skipped', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = resolveDependencies([ + { + specifier: '@studiometa/ui', + entries: { '.': 'bare-npm-name' }, + }, + ]); + + expect(result.importMap).toEqual({}); + expect(result.selfHosted).toEqual([]); + + warn.mockRestore(); + }); + }); + describe('self-hosted paths have no publicPath prefix', () => { it('self-hosted entries always get bare paths without prefix', () => { const result = resolveDependencies([ diff --git a/packages/playground/src/lib/utils/resolve-dependencies.ts b/packages/playground/src/lib/utils/resolve-dependencies.ts index 108d6b3..182263c 100644 --- a/packages/playground/src/lib/utils/resolve-dependencies.ts +++ b/packages/playground/src/lib/utils/resolve-dependencies.ts @@ -61,6 +61,33 @@ export type DependencyConfig = source?: string; /** Explicit entry point (useful when source is a glob pattern) */ entry?: string; + /** + * Multiple entry points for a single workspace package, keyed by their + * export subpath (Node `exports`-style: `.`, `./manifest`, …). All entries + * are built together in **one** tsdown build with code-splitting, so + * modules shared between entries (e.g. component classes referenced by + * both a barrel and a lazy manifest) are emitted **once** as a shared + * chunk and referenced by every entry — a single runtime instance, no + * singleton/identity hazard. + * + * Each key becomes an import-map specifier: `.` maps to `specifier`, + * `./manifest` maps to `specifier/manifest`, etc. Each entry is emitted at + * `static/deps//.js` (the `.` entry keeps the + * `index.js` name for backward compatibility), sharing chunks emitted in + * the same base directory. + * + * Values are local file paths (relative to the consumer config, absolute, + * or glob) — bare npm names are not supported here. Mutually exclusive + * with `entry`; when set, `source` is only used for dev file-watching. + * + * @example + * { + * specifier: '@studiometa/ui', + * source: '../ui/**\/*.ts', + * entries: { '.': '../ui/index.ts', './manifest': '../ui/manifest.ts' }, + * } + */ + entries?: Record; /** * Options passed as query parameters to the esm.sh URL. * Only applies to esm.sh-resolved dependencies (ignored when `source` is set). @@ -71,12 +98,38 @@ export type DependencyConfig = esmSh?: EsmShOptions; }; +/** + * A single resolved entry point of a multi-entry self-hosted dependency. + * Every entry of a package is produced by one shared tsdown build. + */ +export interface ResolvedDependencyEntry { + /** Export subpath as declared in the config (`.`, `./manifest`, …). */ + subpath: string; + /** Full import-map specifier (`@studiometa/ui`, `@studiometa/ui/manifest`, …). */ + specifier: string; + /** + * tsdown/rolldown entry name. Drives the emitted filename `.js`. + * The `.` subpath uses `index` so it keeps the `index.js` contract. + */ + name: string; + /** Local entry source file (relative to the consumer config, or absolute). */ + source: string; + /** Import-map value (`/static/deps//.js`). */ + importMapValue: string; +} + export interface ResolvedDependency { specifier: string; importMapValue: string; type: 'esm-sh' | 'bundle'; source?: string; entry?: string; + /** + * Present when the dependency declares multiple entry points. All entries + * are built together (one tsdown build, code-split) under the base + * `static/deps//` directory. + */ + entries?: ResolvedDependencyEntry[]; } export interface ResolvedDependencies { @@ -191,9 +244,55 @@ export function resolveDependencies( for (const dep of dependencies) { const config = typeof dep === 'string' ? { specifier: dep } : dep; const { specifier, version, source, entry } = config; + const entries = 'entries' in config ? config.entries : undefined; const esmSh = 'esmSh' in config ? config.esmSh : undefined; + if (entries && Object.keys(entries).length > 0) { + // Multi-entry: one code-split build, shared chunks emitted once. + const resolvedEntries: ResolvedDependencyEntry[] = []; + + for (const [subpath, entrySource] of Object.entries(entries)) { + if (!isLocalSource(entrySource)) { + console.warn( + `[playground] Multi-entry dependency "${specifier}" subpath "${subpath}" has a ` + + `non-local source ("${entrySource}"). Only local file paths are supported for ` + + 'entries — this entry is skipped.', + ); + continue; + } + + const fullSpecifier = subpath === '.' ? specifier : `${specifier}${subpath.slice(1)}`; + // `.` keeps the historical `index` name (→ `index.js`); other subpaths + // reuse their path as the rolldown entry name so shared-chunk relative + // imports resolve against the emitted layout. + const name = subpath === '.' ? 'index' : subpath.replace(/^\.\//, ''); + const importMapValue = `/static/deps/${specifier}/${name}.js`; + + importMap[fullSpecifier] = importMapValue; + resolvedEntries.push({ + subpath, + specifier: fullSpecifier, + name, + source: entrySource, + importMapValue, + }); + } + + if (resolvedEntries.length > 0) { + const base = resolvedEntries.find((e) => e.subpath === '.') ?? resolvedEntries[0]; + selfHosted.push({ + specifier, + importMapValue: base.importMapValue, + type: 'bundle', + source, + entries: resolvedEntries, + }); + } + + continue; + } + if (!source) { // esm.sh resolution — split specifier into package name + optional subpath const pkgName = getPackageName(specifier);