@dbx-tools/core 0.9.12 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,654 @@
1
+ /**
2
+ * Resolve npm dependency specifiers against a registry.
3
+ *
4
+ * Callers pass an already-parsed `package.json`. The functions never write that
5
+ * project and never run an installer: they GET packuments over HTTP and walk
6
+ * declared plus transitive `dependencies`. Exact versions stay exact when they
7
+ * exist; ranges stay ranges when a published version satisfies them.
8
+ *
9
+ * `registryUrl` defaults to {@link npmRegistry} from `project-utils`.
10
+ *
11
+ * @module
12
+ */
13
+
14
+ import { json, object } from "@dbx-tools/shared-core";
15
+ import { maxSatisfying, satisfies, valid, validRange } from "semver";
16
+
17
+ import { npmRegistry } from "./project-utils.ts";
18
+
19
+ const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org/";
20
+ const PACKUMENT_TIMEOUT_MS = 30_000;
21
+
22
+ const DEPENDENCY_FIELDS = [
23
+ "dependencies",
24
+ "devDependencies",
25
+ "optionalDependencies",
26
+ "peerDependencies",
27
+ ] as const;
28
+
29
+ const DEFAULT_MAX_ITERATIONS = 100;
30
+
31
+ type DependencyField = (typeof DEPENDENCY_FIELDS)[number];
32
+
33
+ type PackageInfo = {
34
+ name: string;
35
+ versions: string[];
36
+ latest?: string;
37
+ dependencies: Record<string, Record<string, string>>;
38
+ };
39
+
40
+ /** Registry and traversal policy for dependency resolution. */
41
+ export type DependencyResolverOptions = {
42
+ /** Graph-walk attempts before giving up. Defaults to 100. */
43
+ maxIterations?: number;
44
+ /**
45
+ * Registry origin used for packument GETs. Defaults to
46
+ * `projectUtils.npmRegistry()` (env, npmrc, bunfig, pnpm yaml, then public npm).
47
+ */
48
+ registryUrl?: string | URL;
49
+ packageInfo?: (name: string) => Promise<PackageInfo | undefined>;
50
+ skip?: ReadonlySet<string>;
51
+ };
52
+
53
+ /**
54
+ * Package identity plus optional exact version and/or semver range.
55
+ *
56
+ * `semver` is accepted as an input alias for `version`.
57
+ */
58
+ export type DependencyInfo = {
59
+ name: string;
60
+ version?: string;
61
+ semver?: string;
62
+ range?: string;
63
+ };
64
+
65
+ /** Concrete registry version, plus the original range when one was requested. */
66
+ export type ResolvedNpmVersion = {
67
+ name: string;
68
+ version: string;
69
+ range?: string;
70
+ };
71
+
72
+ /** String specifier or structured `{ version, range, name? }` value. */
73
+ export type NpmSpecifierInput = string | Omit<DependencyInfo, "name"> | DependencyInfo;
74
+
75
+ /** Registry dependency maps with resolved versions and retained ranges. */
76
+ export type ResolvedNpmDependencies = {
77
+ dependencies: Record<string, ResolvedNpmVersion>;
78
+ devDependencies: Record<string, ResolvedNpmVersion>;
79
+ optionalDependencies: Record<string, ResolvedNpmVersion>;
80
+ peerDependencies: Record<string, ResolvedNpmVersion>;
81
+ overrides: Record<string, ResolvedNpmVersion>;
82
+ };
83
+
84
+ /** A package whose requested specifier is not in the configured registry. */
85
+ export type MissingDependencyInfo = DependencyInfo;
86
+
87
+ /**
88
+ * Return packages the configured registry cannot satisfy for this manifest.
89
+ *
90
+ * Walks declared registry specifiers and each reachable packument
91
+ * `dependencies` map. The input object is not mutated.
92
+ */
93
+ export async function missingDependencies(
94
+ manifest: Record<string, unknown>,
95
+ options: DependencyResolverOptions = {},
96
+ ): Promise<MissingDependencyInfo[]> {
97
+ const packageInfo = options.packageInfo ?? createPackageInfoLookup(options.registryUrl);
98
+ const overrides = overrideMap(manifest);
99
+ const missing = new Map<string, DependencyInfo>();
100
+ const visited = new Set<string>();
101
+
102
+ const consider = async (dep: DependencyInfo): Promise<void> => {
103
+ if (!isRegistrySpecifier(toNpmSpecifier(dep))) return;
104
+ const request = overrides[dep.name] ?? dep;
105
+ const resolved = await matchingPublishedVersion(request, packageInfo);
106
+ if (resolved) {
107
+ await walk(resolved, packageInfo, visited, consider, options.registryUrl);
108
+ return;
109
+ }
110
+ if (!missing.has(dep.name)) missing.set(dep.name, request);
111
+ };
112
+
113
+ await Promise.all(Object.values(declaredDependencies(manifest)).map(consider));
114
+ return [...missing.values()].sort((left, right) => left.name.localeCompare(right.name));
115
+ }
116
+
117
+ /**
118
+ * Return a registry version for one package.
119
+ *
120
+ * `request` may be a package name, `name@version` / `name@range`, or a
121
+ * structured `{ name, version?, range? }` object. A second string argument is
122
+ * treated as the specifier. An exact version that exists is returned
123
+ * unchanged. A range is retained when a published version satisfies it.
124
+ */
125
+ export async function resolveVersion(
126
+ request: string | DependencyInfo,
127
+ specifierOrOptions?: string | DependencyResolverOptions,
128
+ maybeOptions?: DependencyResolverOptions,
129
+ ): Promise<ResolvedNpmVersion | undefined> {
130
+ const { dependency, options } = resolveVersionArgs(request, specifierOrOptions, maybeOptions);
131
+ if (dependency.version && !isRegistrySpecifier(dependency.version)) {
132
+ return {
133
+ name: dependency.name,
134
+ version: dependency.version,
135
+ ...(dependency.range === undefined ? {} : { range: dependency.range }),
136
+ };
137
+ }
138
+ if (dependency.range && !isRegistrySpecifier(dependency.range)) {
139
+ return { name: dependency.name, version: dependency.range, range: dependency.range };
140
+ }
141
+ const packageInfo = options.packageInfo ?? createPackageInfoLookup(options.registryUrl);
142
+ const info = await packageInfo(dependency.name);
143
+ if (!info) return undefined;
144
+ return pickVersion(info, dependency, options.skip);
145
+ }
146
+
147
+ function resolveVersionArgs(
148
+ request: string | DependencyInfo,
149
+ specifierOrOptions?: string | DependencyResolverOptions,
150
+ maybeOptions?: DependencyResolverOptions,
151
+ ): { dependency: DependencyInfo; options: DependencyResolverOptions } {
152
+ if (typeof specifierOrOptions === "string") {
153
+ const name = typeof request === "string" ? request : request.name;
154
+ return {
155
+ dependency: specifierToDependency(name, specifierOrOptions),
156
+ options: maybeOptions ?? {},
157
+ };
158
+ }
159
+ return {
160
+ dependency: parseDependencyRequest(request),
161
+ options: specifierOrOptions ?? maybeOptions ?? {},
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Return resolvable dependency specifiers for an in-memory `package.json`.
167
+ *
168
+ * The input object is not mutated. Unavailable registry packages (or their
169
+ * parents) are pinned until the packument graph is complete.
170
+ */
171
+ export async function resolveDependencies(
172
+ manifest: Record<string, unknown>,
173
+ options: DependencyResolverOptions = {},
174
+ ): Promise<ResolvedNpmDependencies> {
175
+ const working = structuredClone(manifest);
176
+ const packageInfo = options.packageInfo ?? createPackageInfoLookup(options.registryUrl);
177
+ const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
178
+ const attempted = new Set<string>();
179
+ const shared: DependencyResolverOptions = {
180
+ packageInfo,
181
+ ...(options.registryUrl === undefined ? {} : { registryUrl: options.registryUrl }),
182
+ };
183
+
184
+ for (let iteration = 1; iteration <= maxIterations; iteration += 1) {
185
+ const missing = await missingDependencies(working, shared);
186
+ if (missing.length === 0) return snapshot(working, packageInfo);
187
+
188
+ let changed = false;
189
+ for (const entry of missing) {
190
+ const pin = await pinForMissing(entry, working, packageInfo, attempted);
191
+ if (pin) changed = pinPackage(working, pin) || changed;
192
+ }
193
+ if (!changed) {
194
+ throw new Error(
195
+ `No remaining registry pin for: ${missing.map((entry) => entry.name).join(", ")}`,
196
+ );
197
+ }
198
+ }
199
+
200
+ throw new Error(`Reached ${maxIterations} pin attempts without a complete registry graph.`);
201
+ }
202
+
203
+ async function matchingPublishedVersion(
204
+ request: DependencyInfo,
205
+ packageInfo: (name: string) => Promise<PackageInfo | undefined>,
206
+ ): Promise<ResolvedNpmVersion | undefined> {
207
+ const info = await packageInfo(request.name);
208
+ if (!info) return undefined;
209
+ const version = publishedMatch(info, request);
210
+ if (!version) return undefined;
211
+ return request.range === undefined
212
+ ? { name: info.name, version }
213
+ : { name: info.name, version, range: request.range };
214
+ }
215
+
216
+ function publishedMatch(info: PackageInfo, request: DependencyInfo): string | undefined {
217
+ if (request.range && validRange(request.range)) {
218
+ if (
219
+ request.version &&
220
+ info.versions.includes(request.version) &&
221
+ satisfies(request.version, request.range)
222
+ ) {
223
+ return request.version;
224
+ }
225
+ return maxSatisfying(info.versions, request.range) ?? undefined;
226
+ }
227
+ if (request.version) {
228
+ if (info.versions.includes(request.version)) return request.version;
229
+ if (validRange(request.version)) {
230
+ return maxSatisfying(info.versions, request.version) ?? undefined;
231
+ }
232
+ return undefined;
233
+ }
234
+ return newestPublished(info, info.versions);
235
+ }
236
+
237
+ async function walk(
238
+ resolved: ResolvedNpmVersion,
239
+ packageInfo: (name: string) => Promise<PackageInfo | undefined>,
240
+ visited: Set<string>,
241
+ consider: (dep: DependencyInfo) => Promise<void>,
242
+ registryUrl?: string | URL,
243
+ ): Promise<void> {
244
+ const key = `${resolved.name}@${resolved.version}`;
245
+ if (visited.has(key)) return;
246
+ visited.add(key);
247
+ const info = await packageInfo(resolved.name);
248
+ let deps = info?.dependencies[resolved.version];
249
+ if (deps === undefined && info) {
250
+ deps = await loadVersionDependencies(resolved.name, resolved.version, registryUrl);
251
+ info.dependencies[resolved.version] = deps;
252
+ }
253
+ await Promise.all(
254
+ Object.entries(deps ?? {}).map(([name, spec]) => consider(specifierToDependency(name, spec))),
255
+ );
256
+ }
257
+
258
+ async function snapshot(
259
+ manifest: Record<string, unknown>,
260
+ packageInfo: (name: string) => Promise<PackageInfo | undefined>,
261
+ ): Promise<ResolvedNpmDependencies> {
262
+ return {
263
+ dependencies: await specifierMap(manifest, "dependencies", packageInfo),
264
+ devDependencies: await specifierMap(manifest, "devDependencies", packageInfo),
265
+ optionalDependencies: await specifierMap(manifest, "optionalDependencies", packageInfo),
266
+ peerDependencies: await specifierMap(manifest, "peerDependencies", packageInfo),
267
+ overrides: await structuredMap(overrideMap(manifest), packageInfo),
268
+ };
269
+ }
270
+
271
+ async function specifierMap(
272
+ manifest: Record<string, unknown>,
273
+ field: DependencyField,
274
+ packageInfo: (name: string) => Promise<PackageInfo | undefined>,
275
+ ): Promise<Record<string, ResolvedNpmVersion>> {
276
+ return structuredMap(dependencyMap(manifest, field), packageInfo);
277
+ }
278
+
279
+ async function structuredMap(
280
+ specs: Record<string, DependencyInfo>,
281
+ packageInfo: (name: string) => Promise<PackageInfo | undefined>,
282
+ ): Promise<Record<string, ResolvedNpmVersion>> {
283
+ const out: Record<string, ResolvedNpmVersion> = {};
284
+ for (const [name, spec] of Object.entries(specs)) {
285
+ out[name] = await structuredSpecifier(spec, packageInfo);
286
+ }
287
+ return out;
288
+ }
289
+
290
+ async function structuredSpecifier(
291
+ spec: DependencyInfo,
292
+ packageInfo: (name: string) => Promise<PackageInfo | undefined>,
293
+ ): Promise<ResolvedNpmVersion> {
294
+ const resolved = await resolveVersion(spec, { packageInfo });
295
+ if (resolved) return resolved;
296
+ const fallback = spec.version ?? spec.range ?? spec.semver;
297
+ return fallback === undefined
298
+ ? { name: spec.name, version: "*" }
299
+ : { name: spec.name, version: fallback };
300
+ }
301
+
302
+ function parseDependencyRequest(request: string | DependencyInfo): DependencyInfo {
303
+ if (typeof request !== "string") return normalizeDependency(request);
304
+ const name = stripVersion(request);
305
+ if (name === request) return { name };
306
+ if (!request.startsWith(`${name}@`)) return { name };
307
+ return specifierToDependency(name, request.slice(name.length + 1));
308
+ }
309
+
310
+ function specifierToDependency(name: string, spec: string): DependencyInfo {
311
+ if (!spec || spec === "latest") return { name };
312
+ if (!isRegistrySpecifier(spec)) return { name, version: spec };
313
+ if (valid(spec)) return { name, version: spec };
314
+ if (validRange(spec)) return { name, range: spec };
315
+ return { name, version: spec };
316
+ }
317
+
318
+ function normalizeDependency(request: DependencyInfo): DependencyInfo {
319
+ const version = request.version ?? request.semver;
320
+ const range = request.range;
321
+ const out: DependencyInfo = { name: request.name };
322
+ if (version && !valid(version) && !range && validRange(version) && isRegistrySpecifier(version)) {
323
+ out.range = version;
324
+ return out;
325
+ }
326
+ if (version) out.version = version;
327
+ if (range) out.range = range;
328
+ return out;
329
+ }
330
+
331
+ function parseSpecifierInput(name: string, value: unknown): DependencyInfo | undefined {
332
+ if (typeof value === "string") return specifierToDependency(name, value);
333
+ if (!object.isRecord(value)) return undefined;
334
+ const version = typeof value.version === "string" ? value.version : undefined;
335
+ const semverValue = typeof value.semver === "string" ? value.semver : undefined;
336
+ const range = typeof value.range === "string" ? value.range : undefined;
337
+ const entryName = typeof value.name === "string" ? value.name : name;
338
+ if (!version && !semverValue && !range) return undefined;
339
+ return normalizeDependency({ name: entryName, version, semver: semverValue, range });
340
+ }
341
+
342
+ function dependencyMap(
343
+ manifest: Record<string, unknown>,
344
+ field: DependencyField,
345
+ ): Record<string, DependencyInfo> {
346
+ return parseDependencyField(manifest[field]);
347
+ }
348
+
349
+ function parseDependencyField(value: unknown): Record<string, DependencyInfo> {
350
+ if (!object.isRecord(value)) return {};
351
+ const out: Record<string, DependencyInfo> = {};
352
+ for (const [name, spec] of Object.entries(value)) {
353
+ const parsed = parseSpecifierInput(name, spec);
354
+ if (parsed) out[name] = parsed;
355
+ }
356
+ return out;
357
+ }
358
+
359
+ function declaredDependencies(manifest: Record<string, unknown>): Record<string, DependencyInfo> {
360
+ const out: Record<string, DependencyInfo> = {};
361
+ for (const field of DEPENDENCY_FIELDS) Object.assign(out, dependencyMap(manifest, field));
362
+ return out;
363
+ }
364
+
365
+ function isRegistrySpecifier(spec: string): boolean {
366
+ return !/^(workspace:|catalog:|file:|link:|git\+|github:|https?:|npm:)/i.test(spec);
367
+ }
368
+
369
+ function overrideMap(manifest: Record<string, unknown>): Record<string, DependencyInfo> {
370
+ return parseDependencyField(manifest.overrides);
371
+ }
372
+
373
+ function toNpmSpecifier(dependency: DependencyInfo): string {
374
+ if (dependency.range && dependency.version && isRegistrySpecifier(dependency.range)) {
375
+ if (valid(dependency.version) && satisfies(dependency.version, dependency.range)) {
376
+ return dependency.range;
377
+ }
378
+ }
379
+ if (dependency.range) return dependency.range;
380
+ if (dependency.version) return dependency.version;
381
+ return "*";
382
+ }
383
+
384
+ function pinPackage(manifest: Record<string, unknown>, resolved: ResolvedNpmVersion): boolean {
385
+ let changed = false;
386
+ const overrides = overrideMap(manifest);
387
+ const overridePin: DependencyInfo = { name: resolved.name, version: resolved.version };
388
+ if (toNpmSpecifier(overrides[resolved.name] ?? { name: resolved.name }) !== resolved.version) {
389
+ overrides[resolved.name] = overridePin;
390
+ manifest.overrides = Object.fromEntries(
391
+ Object.entries(overrides).map(([name, dependency]) => [name, dependency]),
392
+ );
393
+ changed = true;
394
+ }
395
+ const depPin: DependencyInfo = {
396
+ name: resolved.name,
397
+ version: resolved.version,
398
+ ...(resolved.range === undefined ? {} : { range: resolved.range }),
399
+ };
400
+ for (const field of DEPENDENCY_FIELDS) {
401
+ const deps = dependencyMap(manifest, field);
402
+ const current = deps[resolved.name];
403
+ if (!current) continue;
404
+ if (!isRegistrySpecifier(toNpmSpecifier(current))) continue;
405
+ if (sameDependency(current, depPin)) continue;
406
+ deps[resolved.name] = depPin;
407
+ manifest[field] = deps;
408
+ changed = true;
409
+ }
410
+ return changed;
411
+ }
412
+
413
+ function sameDependency(left: DependencyInfo, right: DependencyInfo): boolean {
414
+ return left.version === right.version && left.range === right.range;
415
+ }
416
+
417
+ function newestFirst(versions: readonly string[]): string[] {
418
+ return [...versions].sort((left, right) =>
419
+ right.localeCompare(left, undefined, { numeric: true, sensitivity: "base" }),
420
+ );
421
+ }
422
+
423
+ function pickVersion(
424
+ info: PackageInfo,
425
+ request: DependencyInfo,
426
+ skip?: ReadonlySet<string>,
427
+ ): ResolvedNpmVersion | undefined {
428
+ const versions = info.versions.filter((version) => !skip?.has(version));
429
+ if (versions.length === 0) return undefined;
430
+ const fallback = newestPublished(info, versions);
431
+ if (!fallback) return undefined;
432
+ if (request.range && validRange(request.range)) {
433
+ const matched = maxSatisfying(versions, request.range);
434
+ if (
435
+ request.version &&
436
+ versions.includes(request.version) &&
437
+ satisfies(request.version, request.range)
438
+ ) {
439
+ return { name: info.name, version: request.version, range: request.range };
440
+ }
441
+ return matched
442
+ ? { name: info.name, version: matched, range: request.range }
443
+ : { name: info.name, version: fallback, range: request.range };
444
+ }
445
+ if (request.version) {
446
+ return {
447
+ name: info.name,
448
+ version: versions.includes(request.version) ? request.version : fallback,
449
+ };
450
+ }
451
+ return { name: info.name, version: fallback };
452
+ }
453
+
454
+ function newestPublished(info: PackageInfo, versions: readonly string[]): string | undefined {
455
+ if (info.latest && versions.includes(info.latest)) return info.latest;
456
+ return newestFirst(versions)[0];
457
+ }
458
+
459
+ function skippedVersions(name: string, attempted: ReadonlySet<string>): Set<string> {
460
+ const prefix = `${name}@`;
461
+ const skipped = new Set<string>();
462
+ for (const key of attempted) {
463
+ if (key.startsWith(prefix)) skipped.add(key.slice(prefix.length));
464
+ }
465
+ return skipped;
466
+ }
467
+
468
+ function stripVersion(value: string): string {
469
+ if (value.startsWith("@")) {
470
+ const slash = value.indexOf("/");
471
+ const at = value.indexOf("@", slash + 1);
472
+ return at === -1 ? value : value.slice(0, at);
473
+ }
474
+ const at = value.indexOf("@");
475
+ return at === -1 ? value : value.slice(0, at);
476
+ }
477
+
478
+ async function pinForMissing(
479
+ missing: MissingDependencyInfo,
480
+ manifest: Record<string, unknown>,
481
+ packageInfo: (name: string) => Promise<PackageInfo | undefined>,
482
+ attempted: Set<string>,
483
+ ): Promise<ResolvedNpmVersion | undefined> {
484
+ const resolved = await resolveVersion(missing, {
485
+ packageInfo,
486
+ skip: skippedVersions(missing.name, attempted),
487
+ });
488
+ if (resolved) {
489
+ attempted.add(`${missing.name}@${resolved.version}`);
490
+ return resolved;
491
+ }
492
+
493
+ for (const parent of await parentPackages(missing.name, manifest, packageInfo)) {
494
+ const info = await packageInfo(parent);
495
+ if (!info) continue;
496
+ const version = compatibleParentVersion(missing.name, info, attempted);
497
+ if (!version) continue;
498
+ attempted.add(`${parent}@${version}`);
499
+ const declared = declaredDependencies(manifest)[parent] ?? { name: parent };
500
+ return pickVersion({ ...info, versions: [version], latest: version }, declared);
501
+ }
502
+ return undefined;
503
+ }
504
+
505
+ function compatibleParentVersion(
506
+ missing: string,
507
+ info: PackageInfo,
508
+ attempted: Set<string>,
509
+ ): string | undefined {
510
+ for (const version of newestFirst(info.versions)) {
511
+ if (attempted.has(`${info.name}@${version}`)) continue;
512
+ if (!(missing in (info.dependencies[version] ?? {}))) return version;
513
+ }
514
+ return undefined;
515
+ }
516
+
517
+ async function parentPackages(
518
+ missing: string,
519
+ manifest: Record<string, unknown>,
520
+ packageInfo: (name: string) => Promise<PackageInfo | undefined>,
521
+ ): Promise<string[]> {
522
+ const declared = declaredDependencies(manifest);
523
+ const declaredSpec = declared[missing];
524
+ if (declaredSpec && isRegistrySpecifier(toNpmSpecifier(declaredSpec))) return [];
525
+ const parents: string[] = [];
526
+ for (const name of Object.keys(declared)) {
527
+ if (name === missing || !isRegistrySpecifier(toNpmSpecifier(declared[name] ?? { name }))) {
528
+ continue;
529
+ }
530
+ const info = await packageInfo(name);
531
+ if (!info) continue;
532
+ if (Object.values(info.dependencies).some((map) => missing in map)) parents.push(name);
533
+ }
534
+ return parents;
535
+ }
536
+
537
+ function resolveRegistryUrl(registryUrl?: string | URL): string {
538
+ const raw =
539
+ registryUrl?.toString() ||
540
+ npmRegistry(null, { envVars: true })?.toString() ||
541
+ DEFAULT_NPM_REGISTRY;
542
+ return raw.replace(/\/+$/, "");
543
+ }
544
+
545
+ function encodePackageName(name: string): string {
546
+ if (name.startsWith("@")) {
547
+ const slash = name.indexOf("/");
548
+ if (slash !== -1) {
549
+ return `${name.slice(0, slash)}%2f${encodeURIComponent(name.slice(slash + 1))}`;
550
+ }
551
+ }
552
+ return encodeURIComponent(name);
553
+ }
554
+
555
+ function packumentUrl(name: string, registryUrl?: string | URL): string {
556
+ return `${resolveRegistryUrl(registryUrl)}/${encodePackageName(name)}`;
557
+ }
558
+
559
+ async function fetchRegistryJson(url: string): Promise<unknown> {
560
+ const response = await fetch(url, {
561
+ headers: { accept: "application/json" },
562
+ signal: AbortSignal.timeout(PACKUMENT_TIMEOUT_MS),
563
+ }).catch(() => undefined);
564
+ if (!response?.ok) return undefined;
565
+ const text = await response.text();
566
+ return json.parse(text);
567
+ }
568
+
569
+ function createPackageInfoLookup(
570
+ registryUrl?: string | URL,
571
+ ): (name: string) => Promise<PackageInfo | undefined> {
572
+ const cache = new Map<string, Promise<PackageInfo | undefined>>();
573
+ return (name) => {
574
+ const cached = cache.get(name);
575
+ if (cached) return cached;
576
+ const pending = loadPackageInfo(name, registryUrl);
577
+ cache.set(name, pending);
578
+ void pending.then(undefined, () => {
579
+ cache.delete(name);
580
+ });
581
+ return pending;
582
+ };
583
+ }
584
+
585
+ async function loadPackageInfo(
586
+ name: string,
587
+ registryUrl?: string | URL,
588
+ ): Promise<PackageInfo | undefined> {
589
+ const packument = await fetchRegistryJson(packumentUrl(name, registryUrl));
590
+ if (!object.isRecord(packument)) return undefined;
591
+ const versions = publishedVersions(packument);
592
+ if (versions.length === 0) return undefined;
593
+ return {
594
+ name,
595
+ versions,
596
+ latest: latestTag(packument) ?? versions.at(-1),
597
+ dependencies: versionDependencies(packument),
598
+ };
599
+ }
600
+
601
+ async function loadVersionDependencies(
602
+ name: string,
603
+ version: string,
604
+ registryUrl?: string | URL,
605
+ ): Promise<Record<string, string>> {
606
+ const doc = await fetchRegistryJson(
607
+ `${packumentUrl(name, registryUrl)}/${encodeURIComponent(version)}`,
608
+ );
609
+ if (!object.isRecord(doc)) return {};
610
+ return stringMap(doc.dependencies);
611
+ }
612
+
613
+ function publishedVersions(packument: Record<string, unknown>): string[] {
614
+ if (typeof packument.version === "string" && !packument.versions) {
615
+ return [packument.version];
616
+ }
617
+ const versions = packument.versions;
618
+ if (Array.isArray(versions)) {
619
+ return versions.filter((value): value is string => typeof value === "string");
620
+ }
621
+ if (object.isRecord(versions)) return Object.keys(versions);
622
+ return [];
623
+ }
624
+
625
+ function latestTag(packument: Record<string, unknown>): string | undefined {
626
+ const tags = packument["dist-tags"];
627
+ if (!object.isRecord(tags) || typeof tags.latest !== "string") return undefined;
628
+ return tags.latest;
629
+ }
630
+
631
+ function versionDependencies(
632
+ packument: Record<string, unknown>,
633
+ ): Record<string, Record<string, string>> {
634
+ const versions = packument.versions;
635
+ if (!object.isRecord(versions)) {
636
+ return typeof packument.version === "string"
637
+ ? { [packument.version]: stringMap(packument.dependencies) }
638
+ : {};
639
+ }
640
+ const out: Record<string, Record<string, string>> = {};
641
+ for (const [version, value] of Object.entries(versions)) {
642
+ if (object.isRecord(value)) out[version] = stringMap(value.dependencies);
643
+ }
644
+ return out;
645
+ }
646
+
647
+ function stringMap(value: unknown): Record<string, string> {
648
+ if (!object.isRecord(value)) return {};
649
+ const out: Record<string, string> = {};
650
+ for (const [name, spec] of Object.entries(value)) {
651
+ if (typeof spec === "string") out[name] = spec;
652
+ }
653
+ return out;
654
+ }