@exodus/stasis 1.0.0-beta.8 → 1.0.0-beta.9

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.
@@ -9,6 +9,8 @@ import { readFile } from 'node:fs/promises'
9
9
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
10
10
  import { isDeepStrictEqual } from 'node:util'
11
11
 
12
+ import { isValidRepoField } from '@exodus/stasis-core/bundle'
13
+ import { checkoutCommit, parseGithubRepository } from '@exodus/stasis-core/bundle-util'
12
14
  import { assertRealPathWithinBase, isPlainObject, relativeEscapes, toPosix } from '@exodus/stasis-core/util'
13
15
  import { LockfileError, parseComposerLock } from '@preventive/lockfile/composer.js'
14
16
  import { isDir, isFile } from '../resolve-typescript.js'
@@ -532,20 +534,40 @@ function assertInstalledAsLocked(lockText, installedText, { installedFile, vendo
532
534
  return installed
533
535
  }
534
536
 
535
- // The Composer packages of the project at `baseDir`, `[{ name, version, dir, extra }]`, `dir` where
537
+ // The GitHub repository a Composer package `p` (composer.lock's, or installed.json's) is cloned
538
+ // from, at the commit it is installed at: its git `source` on GitHub, at its `reference` where that
539
+ // is a full commit and what is installed at `at` (its directory, or null for none) is at it.
540
+ // Installed from its source -- as installed.json's `installation-source`, `from`, says, or as it
541
+ // must be with no dist, or as a git checkout there shows -- it is a clone Composer checked out at
542
+ // the reference, which may have been moved since: so only where that checkout's HEAD is it. Else
543
+ // from its dist, which Composer installs by default: only one at the same reference, as one with
544
+ // none, or another, is an archive no commit is known of. Packagist takes a package from the root
545
+ // of its repository, so `directory` is ''. Undefined otherwise.
546
+ function composerRepo({ source, dist }, from, at) {
547
+ if (source?.type !== 'git') return undefined
548
+ const fromSource = from === 'source' || dist == null || (at !== null && existsSync(join(at, '.git')))
549
+ if (fromSource ? at === null || checkoutCommit(at) !== source.reference : dist.reference !== source.reference) return undefined
550
+ const github = parseGithubRepository(source.url)
551
+ return github !== null && isValidRepoField('commit', source.reference) ? { github, directory: '', commit: source.reference } : undefined
552
+ }
553
+
554
+ // The Composer packages of the project at `baseDir`, `[{ name, version, dir, extra, repo }]`, `dir` where
536
555
  // the package is installed, baseDir-relative (null for nowhere in baseDir, as a metapackage). Of
537
556
  // its composer.lock where there is one, read by @preventive/lockfile beside composer.json as
538
557
  // `composer install` reads them -- one it refuses stops the build -- each package where Composer
539
558
  // installs it (lockedDir), so no install is needed; else of vendor/composer/installed.json, as
540
559
  // it is. Where both are, installed.json must be the lockfile's install (assertInstalledAsLocked):
541
- // its packages, each where installed.json says.
560
+ // its packages, each where installed.json says. `repo` is composerRepo's.
542
561
  export function loadComposerPackages(baseDir) {
543
562
  const { vendorDir } = readComposerJson(baseDir)
544
563
  const lockText = readUtf8OrNull(join(baseDir, 'composer.lock'), 'composer.lock')
545
564
  if (lockText === null) {
546
565
  return readInstalledPackages(baseDir, vendorDir)
547
566
  .filter((p) => p?.name && p.version)
548
- .map((p) => ({ name: p.name, version: p.version, dir: installedDir(baseDir, vendorDir, p), extra: p.extra }))
567
+ .map((p) => {
568
+ const dir = installedDir(baseDir, vendorDir, p)
569
+ return { name: p.name, version: p.version, dir, extra: p.extra, repo: composerRepo(p, p['installation-source'], dir == null ? null : join(baseDir, dir)) }
570
+ })
549
571
  }
550
572
 
551
573
  const composerJson = readUtf8OrNull(join(baseDir, 'composer.json'), 'composer.json')
@@ -554,9 +576,13 @@ export function loadComposerPackages(baseDir) {
554
576
  const installedText = readUtf8OrNull(join(baseDir, installedFile), installedFile)
555
577
  const installed = installedText === null ? null : assertInstalledAsLocked(lockText, installedText, { installedFile, vendorDir })
556
578
  const dirs = installed === null ? null : new Map(installed.packages.map((p) => [p.name, installedDir(baseDir, vendorDir, p)]))
579
+ const from = new Map(installed?.packages.map((p) => [p.name, p['installation-source']]))
557
580
  return Object.values(lock.packages)
558
581
  .filter((p) => dirs === null || dirs.has(p.name))
559
- .map((p) => ({ name: p.name, version: p.version, dir: dirs === null ? lockedDir(baseDir, vendorDir, p) : dirs.get(p.name), extra: p.extra }))
582
+ .map((p) => {
583
+ const dir = dirs === null ? lockedDir(baseDir, vendorDir, p) : dirs.get(p.name)
584
+ return { name: p.name, version: p.version, dir, extra: p.extra, repo: composerRepo(p, from.get(p.name), dir == null ? null : join(baseDir, dir)) }
585
+ })
560
586
  }
561
587
 
562
588
  // Build the autoload config for `baseDir`, merging the root composer.json
@@ -667,28 +693,28 @@ function matchingPrefixes(map, name) {
667
693
  return [...map].filter(([prefix]) => name.startsWith(prefix)).toSorted((a, b) => b[0].length - a[0].length)
668
694
  }
669
695
 
670
- // The versions of the Composer `packages` (loadComposerPackages: the authoritative source; a
671
- // package's own composer.json usually omits `version`), by where each is installed and by name.
696
+ // The versions and repos of the Composer `packages` (loadComposerPackages: the authoritative source;
697
+ // a package's own composer.json usually omits `version`), by where each is installed and by name.
672
698
  function packageVersions(packages) {
673
699
  const byDir = new Map()
674
700
  const byName = new Map()
675
- for (const { name, version, dir } of packages) {
676
- byName.set(name, version)
677
- if (dir) byDir.set(dir, version)
701
+ for (const { name, version, dir, repo } of packages) {
702
+ byName.set(name, { version, repo })
703
+ if (dir) byDir.set(dir, { version, repo })
678
704
  }
679
705
  return { byDir, byName }
680
706
  }
681
707
 
682
708
  // Walk up from a bundled file to the nearest named composer.json, returning
683
- // { pkgDir, name, version } (pkgDir "." for the root; version from composer.lock or
709
+ // { pkgDir, name, version, repo } (pkgDir "." for the root; version and repo from composer.lock or
684
710
  // installed.json by dir/name, else composer.json `version`, else `fallbackVersion`). Null if none.
685
711
  function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
686
712
  let dir = dirname(fileRelPath)
687
713
  for (;;) {
688
714
  const cj = readJsonIfExists(join(baseDir, dir, 'composer.json'))
689
715
  if (cj?.name) {
690
- const version = installed.byDir.get(dir) ?? installed.byName.get(cj.name) ?? cj.version ?? fallbackVersion
691
- return { pkgDir: dir, name: cj.name, version }
716
+ const known = installed.byDir.get(dir) ?? installed.byName.get(cj.name)
717
+ return { pkgDir: dir, name: cj.name, version: known?.version ?? cj.version ?? fallbackVersion, repo: known?.repo }
692
718
  }
693
719
  if (dir === '.' || dir === '/' || dir === '') return null
694
720
  const parent = dirname(dir)
@@ -700,16 +726,17 @@ function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
700
726
  // Group bundled PHP sources into per-package buckets keyed by the nearest
701
727
  // composer.json's directory (vendor deps get their own; workspace files and
702
728
  // orphans -> the root "." bucket with the placeholder identity). Returns a
703
- // Map<dir, { name, version, files }>. `packages`: loadComposerPackages(baseDir).
729
+ // Map<dir, { name, version, ecosystem?, repo?, files }>, a dependency's `repo` where composerRepo
730
+ // gives one. `packages`: loadComposerPackages(baseDir).
704
731
  export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVersion, packages = loadComposerPackages(baseDir)) {
705
732
  const installed = packageVersions(packages)
706
733
 
707
734
  const modules = new Map()
708
- const ensureBucket = (dir, name, version, ecosystem) => {
735
+ const ensureBucket = (dir, name, version, ecosystem, repo) => {
709
736
  if (!modules.has(dir)) {
710
737
  modules.set(dir, ecosystem === undefined
711
738
  ? { name, version, files: Object.create(null) }
712
- : { name, version, ecosystem, files: Object.create(null) })
739
+ : { name, version, ecosystem, ...(repo === undefined ? {} : { repo }), files: Object.create(null) })
713
740
  }
714
741
  return modules.get(dir)
715
742
  }
@@ -721,7 +748,7 @@ export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVers
721
748
  // Below the root package = an installed Composer dependency, tagged
722
749
  // `composer`; the root "." bucket is workspace code and stays ecosystem-less.
723
750
  const ecosystem = pkg.pkgDir === '.' ? undefined : 'composer'
724
- ensureBucket(pkg.pkgDir, pkg.name, pkg.version, ecosystem).files[rel] = content
751
+ ensureBucket(pkg.pkgDir, pkg.name, pkg.version, ecosystem, pkg.repo).files[rel] = content
725
752
  } else {
726
753
  ensureBucket('.', fallbackName, fallbackVersion).files[path] = content
727
754
  }
@@ -14,17 +14,25 @@ import {
14
14
  typescriptSiblings,
15
15
  } from './resolve-typescript.js'
16
16
  import { diskHost } from '@exodus/stasis-core/host'
17
+ import { createNodeResolver } from './resolve-node.js'
17
18
 
18
19
  // Static module resolver for legacy package fields (`react-native`/`browser`/`main` + browser-spec
19
20
  // redirect maps) and platform suffixes (`.ios`/`.android`/`.native`), reproducing Metro/React-Native
20
21
  // resolution that Node's own resolver (used by scan.js) can't. `exports`-bearing packages are
21
- // delegated to Node. No user code is executed (only package.json is stat/read).
22
+ // delegated to Node's algorithm, under a bundler's conditions rather than Node's (see
23
+ // resolveConditions). No user code is executed (only package.json is stat/read).
22
24
  //
23
25
  // Returns, for each specifier:
24
- // { url } resolved to a real file (file: URL string)
26
+ // { url } resolved to a file, by its real path (file: URL string; see fileResolution)
25
27
  // { empty } a browser/react-native field mapped it to `false` (empty module)
26
- // { builtin } a Node builtin
28
+ // { builtin } a Node builtin: a `node:` specifier, or a builtin name no installed package resolves
27
29
  // null unresolved
30
+ //
31
+ // Browser and React Native targets have no Node builtins, so a bare builtin name (`buffer`,
32
+ // `events`, `util`, `process`, ...) resolves to the installed npm package of that name, as esbuild
33
+ // (platform browser), webpack 5 (target web) and Metro bundle it. Where nothing is installed it
34
+ // stays a builtin, as the --metro-resolver adapter reports Metro's miss on one (metro-resolver.js),
35
+ // rather than an unresolved edge: Metro and webpack fail the build there.
28
36
 
29
37
  // Metro divergence toggle. When a package's browser map maps its OWN entry to `false` (under
30
38
  // Metro's matching rules, which include bare keys like {"buf": false} for main "./buf.js"),
@@ -106,7 +114,21 @@ function resolveEntryThroughMap(map, main, opts) {
106
114
  return { entry: main }
107
115
  }
108
116
 
109
- const fileResolution = (path) => ({ url: pathToFileURL(path).toString() })
117
+ // A resolved file by its REAL path (through `host`, so a Vfs host's links are followed too), as Node,
118
+ // Metro and esbuild record one: under pnpm's isolated layout `node_modules/debug` links into
119
+ // `node_modules/.pnpm/debug@x/node_modules/debug`, whose own imports (a sibling `ms`) resolve only
120
+ // from where it really lies. Probing stays on the path as reached, so a candidate and the package
121
+ // scope read from it (resolveSourceFile's metro redirect) are one spelling; only the hit is
122
+ // realpathed. null where the file has no real path (gone since it was probed).
123
+ function fileResolution(path, host) {
124
+ let real
125
+ try {
126
+ real = host.realpath(path)
127
+ } catch {
128
+ return null
129
+ }
130
+ return { url: pathToFileURL(real).toString() }
131
+ }
110
132
 
111
133
  // Match a package-relative path (`./x`) against the redirect map, trying `.js`/`.json` appended.
112
134
  function matchRedirect(map, relPath) {
@@ -199,7 +221,7 @@ function resolveSourceFile(base, opts) {
199
221
  // bare package-name imports, not to a path landing on a directory.
200
222
  function resolveFileOrDir(base, opts) {
201
223
  // resolveSourceFile yields a path, { empty: true } (a metro candidate-redirect hit), or null.
202
- const asResolution = (hit) => (hit == null ? null : typeof hit === 'string' ? fileResolution(hit) : hit)
224
+ const asResolution = (hit) => (hit == null ? null : typeof hit === 'string' ? fileResolution(hit, opts.host) : hit)
203
225
  const file = asResolution(resolveSourceFile(base, opts))
204
226
  if (file) return file
205
227
  if (isDir(base, opts.host)) {
@@ -219,8 +241,9 @@ function resolveFileOrDir(base, opts) {
219
241
  return null
220
242
  }
221
243
 
222
- // Build a resolver bound to options: `conditions` gates `exports` packages (delegated to Node),
223
- // `mainFields`/`platform`/`preferNative`/`sourceExts` drive the legacy-field + suffix probing.
244
+ // Build a resolver bound to options: `conditions` (a resolveConditions set) gates `exports`
245
+ // packages (delegated to Node's algorithm), `mainFields`/`platform`/`preferNative`/`sourceExts`
246
+ // drive the legacy-field + suffix probing.
224
247
  // `metro` opts into Metro's package-entry + candidate-redirect semantics (see
225
248
  // resolveEntryThroughMap and resolveSourceFile); leave it off for the esbuild-parity
226
249
  // `--mainFields` path. `typescript` adds tsc's extension substitution (a missing `x.js` probes
@@ -244,6 +267,9 @@ export function createFieldResolver({
244
267
  const opts = { platform, preferNative, sourceExts, mainFields, metro, typescript, metroKeepEntryOnBrowserFalse, host }
245
268
  // An importer in node_modules gets no --typescript mapping (inNodeModules), wherever its import lands.
246
269
  const fromNodeModules = { ...opts, typescript: false }
270
+ // Node answers a builtin name with the builtin itself, so an `exports`-bearing package named like
271
+ // one (see the header) resolves through this instead, with builtins off; built on first use.
272
+ let packageResolver
247
273
  // `callConditions` (from scan) is the parent's format-driven condition set, so `exports`
248
274
  // delegation matches Node resolving from THAT file; falls back to configured `conditions`.
249
275
  const resolve = function resolve(parentFile, specifier, callConditions) {
@@ -251,11 +277,28 @@ export function createFieldResolver({
251
277
  const conds = new Set(callConditions ?? conditions)
252
278
  const viaNode = (spec) => {
253
279
  try {
254
- return fileResolution(host.resolve(parentFile, spec, conds))
280
+ if (!isBuiltin(spec)) return fileResolution(host.resolve(parentFile, spec, conds), host)
281
+ packageResolver ??= createNodeResolver(host)
282
+ return fileResolution(packageResolver.resolve(parentFile, spec, conds, { builtins: false }), host)
255
283
  } catch {
256
284
  return null
257
285
  }
258
286
  }
287
+ // A bare specifier (`mod`, `mod/sub`) through node_modules.
288
+ const viaPackage = (spec) => {
289
+ const loc = locatePackage(dirname(parentFile), spec, host)
290
+ if (!loc) return null
291
+ const pkg = readJson(join(loc.pkgDir, 'package.json'), host) ?? {}
292
+ // `exports` wins over mainFields; Node's algorithm resolves it (with conditions) correctly.
293
+ if (pkg.exports != null) return viaNode(spec)
294
+ // A bare package import resolves its directory via the same dir algorithm as any other.
295
+ if (loc.subpath === '') return resolveFileOrDir(loc.pkgDir, fileOpts)
296
+ const sub = `./${loc.subpath}`
297
+ const r = matchRedirect(mergeRedirectMap(pkg, mainFields), sub)
298
+ if (r === false) return { empty: true }
299
+ const target = typeof r === 'string' ? r : sub
300
+ return resolveFileOrDir(join(loc.pkgDir, target), fileOpts)
301
+ }
259
302
  // `#name` subpath imports use the `imports` field + conditions; Node's algorithm handles them.
260
303
  if (specifier.startsWith('#')) return viaNode(specifier)
261
304
 
@@ -283,26 +326,15 @@ export function createFieldResolver({
283
326
  }
284
327
  }
285
328
 
286
- // A builtin the browser map did not remap resolves to the builtin itself.
287
- if (isBuiltin(spec)) return { builtin: true }
288
-
289
329
  if (spec.startsWith('.') || isAbsolute(spec)) {
290
330
  const base = isAbsolute(spec) ? spec : resolvePath(dirname(parentFile), spec)
291
331
  return resolveFileOrDir(base, fileOpts)
292
332
  }
293
333
 
294
- const loc = locatePackage(dirname(parentFile), spec, host)
295
- if (!loc) return null
296
- const pkg = readJson(join(loc.pkgDir, 'package.json'), host) ?? {}
297
- // `exports` wins over mainFields; Node's algorithm resolves it (with conditions) correctly.
298
- if (pkg.exports != null) return viaNode(spec)
299
- // A bare package import resolves its directory via the same dir algorithm as any other.
300
- if (loc.subpath === '') return resolveFileOrDir(loc.pkgDir, fileOpts)
301
- const sub = `./${loc.subpath}`
302
- const r = matchRedirect(mergeRedirectMap(pkg, mainFields), sub)
303
- if (r === false) return { empty: true }
304
- const target = typeof r === 'string' ? r : sub
305
- return resolveFileOrDir(join(loc.pkgDir, target), fileOpts)
334
+ // A builtin the browser map did not remap: its `node:` form names the builtin itself; a bare
335
+ // name is the installed package of that name (see the header), the builtin where none resolves.
336
+ if (isBuiltin(spec)) return spec.startsWith('node:') ? { builtin: true } : (viaPackage(spec) ?? { builtin: true })
337
+ return viaPackage(spec)
306
338
  }
307
339
  if (!typescript) return resolve
308
340
  // --typescript: when the whole field flow leaves the specifier unresolved, give tsc's mapping
@@ -319,7 +351,7 @@ export function createFieldResolver({
319
351
  paths: typescriptPaths,
320
352
  host,
321
353
  })
322
- return hit == null ? null : fileResolution(hit)
354
+ return hit == null ? null : fileResolution(hit, host)
323
355
  }
324
356
  }
325
357
 
@@ -328,11 +360,11 @@ function isModuleFormat(format) {
328
360
  return format === 'module' || format === 'module-typescript'
329
361
  }
330
362
 
331
- // Condition set for a parent of the given format: Node's format-driven base (matching scan's
332
- // #conditionsFor) plus extras. Order is irrelevant (Node membership-tests against exports key order).
363
+ // Condition set for a parent of the given format, as scan's #conditionSet gives a custom resolver:
364
+ // `import` or `require` by format, `default` (which Node's resolver matches anyway), plus extras.
365
+ // A bundler's base, not Node's: esbuild, webpack and Metro assert `import`/`require` and
366
+ // `default`, never `node`, so an `exports` map listing `node` first can't win over the extras.
367
+ // Order is irrelevant (Node membership-tests against exports key order).
333
368
  export function resolveConditions(format, extras = []) {
334
- const base = isModuleFormat(format)
335
- ? ['node', 'import', 'module-sync', 'node-addons']
336
- : ['node', 'require', 'module-sync', 'node-addons']
337
- return [...new Set([...base, ...extras])]
369
+ return [...new Set([isModuleFormat(format) ? 'import' : 'require', 'default', ...extras])]
338
370
  }
@@ -385,9 +385,10 @@ export function createNodeResolver(host) {
385
385
 
386
386
  const notFound = (request, parentFile) => Object.assign(codedError('MODULE_NOT_FOUND', `Cannot find module '${request}'\nRequire stack:\n- ${parentFile}`), { requireStack: [parentFile] })
387
387
 
388
- // Module._resolveFilename: -> the file's real path, or a builtin's id.
389
- const resolveFilename = (parentFile, request, conditions) => {
390
- if (isBuiltin(request)) return request
388
+ // Module._resolveFilename: -> the file's real path, or a builtin's id. Without `builtins`, a
389
+ // request naming a builtin is looked up like any bare name (see resolve).
390
+ const resolveFilename = (parentFile, request, conditions, builtins) => {
391
+ if (builtins && isBuiltin(request)) return request
391
392
  if (request === '') throw notFound(request, parentFile)
392
393
  if (request[0] === '#') {
393
394
  const pkg = readPackageScope(parentFile)
@@ -404,12 +405,14 @@ export function createNodeResolver(host) {
404
405
 
405
406
  return {
406
407
  // `conditions`: `require.resolve`'s own by default. A builtin returns its id, as it does.
407
- resolve(parentFile, request, conditions = REQUIRE_CONDITIONS) {
408
+ // `builtins: false` resolves for a target with no Node builtins: a bare builtin name (`buffer`)
409
+ // is the installed package of that name, through its `exports` like any other.
410
+ resolve(parentFile, request, conditions = REQUIRE_CONDITIONS, { builtins = true } = {}) {
408
411
  parentFile = resolve(parentFile)
409
412
  if (!(conditions instanceof Set)) conditions = new Set(conditions)
410
- const key = `${dirname(parentFile)}\0${request}\0${[...conditions].join(',')}`
413
+ const key = `${dirname(parentFile)}\0${request}\0${[...conditions].join(',')}${builtins ? '' : '\0nobuiltins'}`
411
414
  let hit = memo.get(key)
412
- if (hit === undefined) memo.set(key, hit = resolveFilename(parentFile, request, conditions))
415
+ if (hit === undefined) memo.set(key, hit = resolveFilename(parentFile, request, conditions, builtins))
413
416
  return hit
414
417
  },
415
418
  }
package/src/scan.js CHANGED
@@ -237,10 +237,15 @@ export class Scan {
237
237
  return this
238
238
  }
239
239
 
240
- // Must match the runtime resolver's condition set (omitting one resolves `exports`-gated
241
- // packages to a different file) AND its order: the recorded key must equal the runtime
242
- // hooks' string for exact lookups. Extras go where Node puts user conditions in each set.
240
+ // Node's resolver: must match the runtime resolver's condition set (omitting one resolves
241
+ // `exports`-gated packages to a different file) AND its order: the recorded key must equal the
242
+ // runtime hooks' string for exact lookups. Extras go where Node puts user conditions in each set.
243
+ // A custom resolver (--mainFields/--metro) resolves for a bundler instead, so it gets what
244
+ // esbuild, webpack and Metro all assert: `import`/`require`, `default` (which Node's resolver
245
+ // matches anyway) and the extras, never `node`, `node-addons` or `module-sync`; the same set as
246
+ // resolve-fields.js's resolveConditions.
243
247
  #conditionSet(context) {
248
+ if (this.customResolve) return new Set([context, 'default', ...this.extraConditions])
244
249
  return context === 'import'
245
250
  ? new Set(['node', 'import', 'module-sync', 'node-addons', ...this.extraConditions])
246
251
  : new Set(['require', 'node', 'node-addons', ...this.extraConditions, 'module-sync'])
@@ -6,12 +6,15 @@ import { isAutoExcludedDir, isPlainObject, posixPathEscapes, relativeEscapes } f
6
6
  import { Vfs } from '@preventive/vfs'
7
7
  import { checkVfsOptions, fieldResolverFor } from '../cmd/bundle.js'
8
8
  import { foundrySourceDir } from '../loaders/foundry.js'
9
+ import { resolveConditions } from '../resolve-fields.js'
9
10
  import { resolveTypescriptFallback, typescriptExportsTarget } from '../resolve-typescript.js'
10
11
  import { checkKind, checkVersion, packageManagerOf, vfsHost } from './tree.js'
11
12
 
12
13
  const JS = /\.(?:[cm]?[jt]s|[jt]sx)$/u
13
- // Node's own conditions for require() and for import, which the build's are added to.
14
+ // Node's own conditions for require() and for import, which the build's are added to; with
15
+ // `mainFields` or `metro`, the field resolver's bundler ones instead (no `node`).
14
16
  const NODE_CONDITIONS = [['require', 'node', 'node-addons', 'module-sync'], ['import', 'node', 'node-addons', 'module-sync']]
17
+ const FIELD_CONDITIONS = ['commonjs', 'module'].map((format) => resolveConditions(format))
15
18
 
16
19
  // The entry points the package.json in `dir` names, as paths from `dir`, resolved as the JS build
17
20
  // with the given options resolves: as Node does, `conditions` added, or with `mainFields` or
@@ -19,8 +22,9 @@ const NODE_CONDITIONS = [['require', 'node', 'node-addons', 'module-sync'], ['im
19
22
  // that misses mapped as tsc maps it under `typescript`, as the build's fallback maps it. In
20
23
  // that order: its own entry, as the build resolves `./` there (`main`, or the first of the main
21
24
  // fields, else index); each subpath `exports` holds (but a pattern), as the package's name resolves
22
- // for require() and for import, with the conditions the build adds (the RN ones under `metro`); and
23
- // each `bin`. Only JS files in `dir` that are there, named from within it; none without a package.json.
25
+ // for require() and for import, with the conditions the build adds (the RN ones under `metro`) to
26
+ // Node's, or with `mainFields` or `metro` to the field resolver's; and each `bin`. Only JS files in
27
+ // `dir` that are there, named from within it; none without a package.json.
24
28
  function packageEntries(host, dir, { conditions = [], mainFields, metro = false, platforms = [], typescript = false } = {}) {
25
29
  const real = host.realpath(dir)
26
30
  const manifest = join(real, 'package.json')
@@ -62,8 +66,8 @@ function packageEntries(host, dir, { conditions = [], mainFields, metro = false,
62
66
  for (const subpath of typeof name === 'string' ? subpaths : []) {
63
67
  if (!subpath.startsWith('.') || subpath.includes('*') || subpath.endsWith('/')) continue
64
68
  const specifier = subpath === '.' ? name : `${name}${subpath.slice(1)}`
65
- for (const { extras } of passes) {
66
- for (const names of NODE_CONDITIONS) add(viaNode(specifier, names, extras, (set) => typescriptExportsTarget(real, exports, subpath, { conditions: set, host })))
69
+ for (const { extras, resolver } of passes) {
70
+ for (const names of resolver === undefined ? NODE_CONDITIONS : FIELD_CONDITIONS) add(viaNode(specifier, names, extras, (set) => typescriptExportsTarget(real, exports, subpath, { conditions: set, host })))
67
71
  }
68
72
  }
69
73
  const bins = typeof pkg.bin === 'string' ? [pkg.bin] : pkg.bin !== null && typeof pkg.bin === 'object' ? Object.values(pkg.bin) : []
@@ -8,7 +8,7 @@ import { createClient } from '@preventive/upstream/github.js'
8
8
  import { vfsFromEntries } from '@preventive/vfs'
9
9
  import { buildVfsBundle } from '../cmd/bundle.js'
10
10
  import { KINDS, checkAhead } from './entries.js'
11
- import { checkTarget, detectPackageManager, installedAlone, lockfileOf, lockfilesListed, noLockfile, packageManagerOf, vfsHost } from './tree.js'
11
+ import { checkCache, checkTarget, detectPackageManager, installedAlone, lockfileOf, lockfilesListed, noLockfile, packageManagerOf, vfsHost } from './tree.js'
12
12
 
13
13
  // As upstream's tree verification bounds a tarball's unpacked size.
14
14
  const MAX_TAR_BYTES = 2 ** 30
@@ -172,8 +172,11 @@ async function repoTree(name, { github, sha, tag, directory, client, packageMana
172
172
  // one is outside the others; `repo` names that directory and the commit. Nothing is read from disk:
173
173
  // the tree's bytes come from GitHub, or from the cache setCacheDir names, held to the git tree id
174
174
  // either way (@preventive/upstream), and are unpacked into a Vfs that buildVfsBundle reads alone.
175
+ // `cache` is buildVfsBundle's, for the npm packages the tree installs: the repo's tree is cached
176
+ // where setCacheDir says, whatever it is.
175
177
  export async function buildGitHubBundle({ github, sha, tag, directory, client, packageManager, ...options } = {}) {
176
178
  checkTarget('buildGitHubBundle', options)
179
+ checkCache('buildGitHubBundle', options.cache)
177
180
  // Its own, for a Soldeer git dependency too.
178
181
  client ??= createClient({ token: null })
179
182
  const tree = await repoTree('buildGitHubBundle', { github, sha, tag, directory, client, packageManager }, options)
@@ -1,7 +1,8 @@
1
1
  import { constants } from 'node:fs'
2
- import { basename, dirname, join, relative, resolve, sep } from 'node:path'
2
+ import { basename, dirname, join, posix, relative, resolve, sep } from 'node:path'
3
3
 
4
- import { NO_ENTRY, packageJSONStat, packageJSONText, readJson } from '@exodus/stasis-core/bundle-util'
4
+ import { isValidRepoField } from '@exodus/stasis-core/bundle'
5
+ import { NO_ENTRY, packageJSONStat, packageJSONText, parseGithubRepository, readJson } from '@exodus/stasis-core/bundle-util'
5
6
  import { byName } from '@exodus/stasis-core/host'
6
7
  import { hasNodeModulesSegment, isPlainObject } from '@exodus/stasis-core/util'
7
8
  import { buildNpmTree, findNpmWorkspaces } from '@preventive/deptree/npm.js'
@@ -114,6 +115,14 @@ export function checkTarget(name, { os, cpu, libc }) {
114
115
  if (libc !== undefined && !LIBCS.has(libc)) throw new TypeError(`${name}: libc must be one of 'glibc', 'musl', 'unknown'`)
115
116
  }
116
117
 
118
+ // `cache`, where npm packages' tarballs and version documents are kept, as @preventive/upstream's
119
+ // CacheOptions take it: left out, in setCacheDir's cache; a store with `read` and `write`, in place
120
+ // of it; or false, nowhere. Checked as deptree checks it, before anything is fetched.
121
+ export function checkCache(name, cache) {
122
+ if (cache === undefined || cache === false || (typeof cache?.read === 'function' && typeof cache?.write === 'function')) return
123
+ throw new TypeError(`${name}: cache must be false, or a store with read and write, or left out`)
124
+ }
125
+
117
126
  // yarn installs a workspace from the root that declares it, whatever yarn.lock is nearer, and any
118
127
  // other package from the nearest yarn.lock.
119
128
  function yarn1Root(host, cwd) {
@@ -191,7 +200,7 @@ const PACKAGE_MANAGERS = {
191
200
  alone: async (names, above) => !(await above()).some((dir) => dir.includes('pnpm-workspace.yaml')),
192
201
  projects: (view, pnpm) => findPnpmProjects({ project: view, host: { pnpm } }),
193
202
  installs: 'node_modules',
194
- build: (view, pnpm, _file, given) => buildPnpmTree({ project: view, host: { pnpm, ...target(given) } }),
203
+ build: (view, pnpm, _file, given) => buildPnpmTree({ project: view, host: { pnpm, ...target(given) }, cache: given?.cache }),
195
204
  },
196
205
  yarn1: {
197
206
  kind: 'js',
@@ -205,7 +214,7 @@ const PACKAGE_MANAGERS = {
205
214
  installs: 'node_modules',
206
215
  build: (view, yarn, file, given) => {
207
216
  const { libc: _, ...host } = target({ ...given, libc: 'unknown' }) // yarn 1 matches no libc
208
- return naming(file, buildYarn1Tree({ project: view, host: { yarn, ...host } }))
217
+ return naming(file, buildYarn1Tree({ project: view, host: { yarn, ...host }, cache: given?.cache }))
209
218
  },
210
219
  },
211
220
  npm: {
@@ -218,7 +227,7 @@ const PACKAGE_MANAGERS = {
218
227
  alone: async (names, above) => !(await above()).some((dir) => dir.includes('package.json')),
219
228
  projects: (view, _npm, given) => findNpmWorkspaces({ project: view, os: target(given).os }),
220
229
  installs: 'node_modules',
221
- build: (view, npm, file, given) => naming(file, buildNpmTree({ project: view, host: npmHost(npm, given) })),
230
+ build: (view, npm, file, given) => naming(file, buildNpmTree({ project: view, host: npmHost(npm, given), cache: given?.cache })),
222
231
  },
223
232
  soldeer: {
224
233
  kind: 'sol',
@@ -343,15 +352,17 @@ export function checkVfs(name, vfs) {
343
352
  }
344
353
  }
345
354
 
346
- // -> { root, vfs, projects, stats, packageManager, packageManagerVersion }, of the project `project`
347
- // holds that `cwd` is in, as `packageManager` installs it: the directory it installs from, as a real
348
- // path; a new Vfs holding the tree, rooted there; the directories of the projects it finds, from
349
- // there; deptree's counts; and the version reproduced, `packageManagerVersion` if given, else the one
350
- // the root package.json's packageManager pins, else the default. deptree reads the project through a
351
- // view of `project`, which nothing is written through. `client`, a @preventive/upstream/github.js
352
- // client, is what a Soldeer git dependency is fetched through (deptree's `github`); without one, a
353
- // lockfile holding one is refused before anything is fetched.
354
- async function layOutTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc, client }) {
355
+ // -> { root, vfs, projects, stats, installed, packageManager, packageManagerVersion }, of the project
356
+ // `project` holds that `cwd` is in, as `packageManager` installs it: the directory it installs from,
357
+ // as a real path; a new Vfs holding the tree, rooted there; the directories of the projects it finds,
358
+ // from there; deptree's counts, and what it installs, each by its `path` from there; and the version
359
+ // reproduced, `packageManagerVersion` if given, else the one the root package.json's packageManager
360
+ // pins, else the default. deptree reads the project through a view of `project`, which nothing is
361
+ // written through. `client`, a @preventive/upstream/github.js client, is what a Soldeer git
362
+ // dependency is fetched through (deptree's `github`); without one, a lockfile holding one is refused
363
+ // before anything is fetched. `cache` (checkCache) is where npm packages' tarballs and version
364
+ // documents are kept, which deptree fetches for pnpm, yarn1 and npm.
365
+ async function layOutTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc, client, cache }) {
355
366
  const pm = PACKAGE_MANAGERS[packageManager]
356
367
  const found = pm.root(project, cwd, os)
357
368
  if (found === null) throw new Error(`no ${pm.lockfile} found in ${cwd} or any parent directory`)
@@ -364,17 +375,40 @@ async function layOutTree({ project, packageManager, cwd, packageManagerVersion,
364
375
  const projects = new Set(pm.projects(view, version, { os, cpu, libc }))
365
376
  const other = pm.kind === 'js' ? outsider(project, found, cwd, projects) : null
366
377
  if (other !== null) throw new Error(`${file} does not install ${other}: it is none of the lockfile's projects`)
367
- const { vfs, stats } = await pm.build(view, version, file, { os, cpu, libc, client })
378
+ const { vfs, stats, installed } = await pm.build(view, version, file, { os, cpu, libc, client, cache })
368
379
  return {
369
380
  root: project.realpath(found),
370
381
  vfs,
371
382
  projects,
372
383
  stats,
384
+ installed,
373
385
  packageManager,
374
386
  packageManagerVersion: version ?? (String(pinned).startsWith(`${pm.pin}@`) ? /^[^@]+@([^+]+)/u.exec(pinned)[1] : undefined),
375
387
  }
376
388
  }
377
389
 
390
+ // The root of the GitHub repository a git URL names, or undefined for another host's.
391
+ const githubRoot = (url) => {
392
+ const github = parseGithubRepository(url)
393
+ return github === null ? undefined : { github, directory: '' }
394
+ }
395
+
396
+ // Each dependency of `bundle`, its paths from `root`, at the commit the tree (layOutTree's) installs
397
+ // it from, where deptree names a full one: an npm package at its gitHead, which the registry's
398
+ // version document names, a commit of the repository its own package.json names, so taken only
399
+ // where the bundle records that one; a Soldeer git dependency at its lockfile's `rev`, the root of
400
+ // the repository it is fetched from, where that is a GitHub one. Metadata, as `repo` is: the
401
+ // publisher's word, held to no repository.
402
+ export function pinCommits(bundle, root, { root: from, installed }) {
403
+ const byDir = new Map(installed.map((pkg) => [posix.relative(root, posix.join(from, pkg.path)), pkg]))
404
+ for (const [dir, info] of bundle.modules) {
405
+ const pkg = byDir.get(dir)
406
+ if (!isValidRepoField('commit', pkg?.commit)) continue
407
+ const repo = info.repo ?? githubRoot(pkg.git)
408
+ if (repo !== undefined) bundle.modules.set(dir, { ...info, repo: { ...repo, commit: pkg.commit } })
409
+ }
410
+ }
411
+
378
412
  // fs.Stats#mode carries the file type above the permission bits a Vfs stat holds.
379
413
  const TYPE_BITS = { file: constants.S_IFREG, directory: constants.S_IFDIR, symlink: constants.S_IFLNK }
380
414
 
package/src/vfs-bundle.js CHANGED
@@ -3,7 +3,7 @@ import { posix } from 'node:path'
3
3
  import { Vfs } from '@preventive/vfs'
4
4
  import { KINDS, checkAhead } from './vfs-bundle/entries.js'
5
5
  import { suggestedRepoEntries } from './vfs-bundle/github.js'
6
- import { NODE_MODULES_MANAGERS, checkTarget, checkVfs, detectPackageManager, loadTree, packageManagerFor, vfsHost } from './vfs-bundle/tree.js'
6
+ import { NODE_MODULES_MANAGERS, checkCache, checkTarget, checkVfs, detectPackageManager, loadTree, packageManagerFor, vfsHost } from './vfs-bundle/tree.js'
7
7
 
8
8
  // @exodus/stasis/vfs-bundle: static bundles from a project's lockfile alone, through the
9
9
  // dependencies its package manager would install (`packageManager`: 'pnpm', pnpm 9, 10, 11 or 12;
@@ -30,10 +30,12 @@ export function createVfsHost(vfs) {
30
30
  return vfsHost(vfs, { cache: false })
31
31
  }
32
32
 
33
- // -> { root, vfs, projects, host, stats, packageManager, packageManagerVersion }, as 'pnpm',
34
- // 'yarn1' or 'npm' installs node_modules: the directory in the project's Vfs it installs cwd from,
35
- // which holds the lockfile; the Vfs the tree is laid out into, rooted there; the projects'
36
- // directories from there; the host reading the project through the tree; deptree's counts; and the
33
+ // -> { root, vfs, projects, host, stats, installed, packageManager, packageManagerVersion }, as
34
+ // 'pnpm', 'yarn1' or 'npm' installs node_modules: the directory in the project's Vfs it installs cwd
35
+ // from, which holds the lockfile; the Vfs the tree is laid out into, rooted there; the projects'
36
+ // directories from there; the host reading the project through the tree; deptree's counts, and the
37
+ // packages it installs (each by its `path` from there, with the `commit` its version document
38
+ // names, where it names a full one); and the
37
39
  // package manager reproduced, at `packageManagerVersion` if given, else the one the root
38
40
  // package.json's packageManager pins, else pnpm 10.33.4, yarn 1.22.22 or npm 11.21.0 (which nothing
39
41
  // pins: npm reads no packageManager). The host caches what it reads, of the project's Vfs too, so
@@ -41,15 +43,18 @@ export function createVfsHost(vfs) {
41
43
  // `os`, `cpu` and `libc` ('glibc', 'musl' or 'unknown', pnpm's and npm's, which takes 'unknown' for
42
44
  // none) are the machine packages are matched against: this one's but for what is given (for another
43
45
  // os, libc defaults to 'unknown'). Without a `packageManager`, it is the one of the three whose
44
- // lockfile installs cwd, where only one's does.
45
- export async function loadNodeModules({ vfs, packageManager, cwd = '/', packageManagerVersion, os, cpu, libc } = {}) {
46
+ // lockfile installs cwd, where only one's does. `cache` is where the packages' tarballs and version
47
+ // documents are kept, as @preventive/upstream's CacheOptions take it: left out, in setCacheDir's
48
+ // cache; a store with `read` and `write`, in place of it; or false, nowhere.
49
+ export async function loadNodeModules({ vfs, packageManager, cwd = '/', packageManagerVersion, os, cpu, libc, cache } = {}) {
46
50
  checkVfs('loadNodeModules', vfs)
47
51
  checkTarget('loadNodeModules', { os, cpu, libc })
52
+ checkCache('loadNodeModules', cache)
48
53
  const project = vfsHost(vfs)
49
54
  // A real path, as detection and the layout take it.
50
55
  cwd = project.realpath(posix.resolve('/', cwd))
51
56
  packageManager = packageManagerFor('loadNodeModules', project, cwd, { packageManager, packageManagerVersion, os }, NODE_MODULES_MANAGERS)
52
- return loadTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc })
57
+ return loadTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc, cache })
53
58
  }
54
59
 
55
60
  // -> the entries buildGitHubBundle takes where none are given, as paths from the project's