@exodus/stasis 1.0.0-beta.7 → 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.
@@ -0,0 +1,182 @@
1
+ import { hash } from 'node:crypto'
2
+ import { dirname, join, relative, resolve } from 'node:path'
3
+
4
+ import { toBase32 } from '@exodus/bytes/base32.js'
5
+ import { isValidRepoField } from '@exodus/stasis-core/bundle'
6
+ import { parseGithubRepository, readJson, readText } from '@exodus/stasis-core/bundle-util'
7
+ import { diskHost } from '@exodus/stasis-core/host'
8
+ import { isPlainObject, relativeEscapes, toPosix } from '@exodus/stasis-core/util'
9
+ import { parsePnpmLockfile } from '@preventive/lockfile/pnpm.js'
10
+ import { parseYarn1Lockfile } from '@preventive/lockfile/yarn1.js'
11
+
12
+ // The commit each git dependency in a node_modules on disk is installed at, as the package manager
13
+ // that laid it out recorded it there: npm in its hidden lockfile, pnpm in its copy of the lockfile,
14
+ // yarn 1 in yarn.lock, as its .yarn-integrity has it installed. Nothing on disk names a registry
15
+ // package's. A record missing, unreadable, or naming no full commit of a GitHub repository records
16
+ // nothing, and fails nothing.
17
+
18
+ // `{ github, commit }` of `repo`, a git URL, at `commit`, where that is a GitHub repository and a full
19
+ // commit id; else undefined.
20
+ function githubCommit(repo, commit) {
21
+ const github = parseGithubRepository(repo)
22
+ return github !== null && isValidRepoField('commit', commit) ? { github, commit } : undefined
23
+ }
24
+
25
+ // GitHub's tarball of a commit, as yarn 1 and pnpm fetch a GitHub dependency.
26
+ const CODELOAD = /^https:\/\/codeload\.github\.com\/([^/]+\/[^/]+)\/tar\.gz\/([^/]+)$/u
27
+ const githubTarball = (url) => {
28
+ const [, github, commit] = CODELOAD.exec(url) ?? []
29
+ return isValidRepoField('github', github) ? githubCommit(github, commit) : undefined
30
+ }
31
+
32
+ // githubCommit of a git or tarball resolution @preventive/lockfile reads.
33
+ const resolutionCommit = (resolution) => {
34
+ if (resolution?.type === 'git') return githubCommit(resolution.repo, resolution.commit)
35
+ return resolution?.type === 'tarball' ? githubTarball(resolution.tarball ?? '') : undefined
36
+ }
37
+
38
+ // The lookup of a record that finds nothing: one unreadable, or none.
39
+ const NONE = () => undefined
40
+
41
+ // npm's, from npm 7: the hidden lockfile it writes into `dir`'s node_modules as it lays it out, by
42
+ // each package's location from `dir`, where it records the version installed there. A git
43
+ // dependency's `resolved` is the URL npm clones, `#` and the commit.
44
+ function npmRecord(dir, host) {
45
+ const packages = readJson(join(dir, 'node_modules', '.package-lock.json'), host)?.packages
46
+ if (!isPlainObject(packages)) return NONE
47
+ return (location, version) => {
48
+ const entry = Object.hasOwn(packages, location) ? packages[location] : undefined
49
+ if (!isPlainObject(entry) || entry.version !== version || typeof entry.resolved !== 'string') return undefined
50
+ const { resolved } = entry
51
+ if (!/^git(?:\+[\w.-]+)?:/u.test(resolved)) return githubTarball(resolved)
52
+ const at = resolved.lastIndexOf('#')
53
+ return at === -1 ? undefined : githubCommit(resolved.slice(0, at), resolved.slice(at + 1))
54
+ }
55
+ }
56
+
57
+ // yarn 1's: yarn.lock in `dir`, by the name a package is installed as, the last of its location, and
58
+ // its version, where one entry alone has both. The .yarn-integrity yarn writes into the node_modules
59
+ // it lays out has to hold the entry's resolution for each of its patterns, as yarn.lock's `resolved`
60
+ // spells it: a yarn.lock changed since often moves a git dependency to another commit of one version.
61
+ function yarnRecord(dir, host) {
62
+ const installed = readJson(join(dir, 'node_modules', '.yarn-integrity'), host)?.lockfileEntries
63
+ const text = readText(host, join(dir, 'yarn.lock'))
64
+ if (!isPlainObject(installed) || text === null) return NONE
65
+ let packages
66
+ try {
67
+ packages = parseYarn1Lockfile(text, { checkVersions: false }).packages
68
+ } catch {
69
+ return NONE // whatever the reader refuses
70
+ }
71
+ const byVersion = new Map()
72
+ for (const { patterns, name, version, resolution } of new Set(Object.values(packages))) {
73
+ let commit = resolutionCommit(resolution)
74
+ const resolved = resolution?.type === 'git' ? `${resolution.repo}#${resolution.commit}` : resolution?.sha1 === undefined ? resolution?.tarball : `${resolution.tarball}#${resolution.sha1}`
75
+ if (!patterns.every((pattern) => Object.hasOwn(installed, pattern) && installed[pattern] === resolved)) commit = undefined
76
+ // Of two entries of one name and version, either may be the one installed.
77
+ const id = `${name}@${version}`
78
+ byVersion.set(id, byVersion.has(id) ? undefined : commit)
79
+ }
80
+ return (location, version) => byVersion.get(`${location.slice(location.lastIndexOf('node_modules/') + 'node_modules/'.length)}@${version}`)
81
+ }
82
+
83
+ // What npm and yarn leave in a node_modules they lay out, and the reader of each one's record.
84
+ const LAYOUTS = [['.package-lock.json', npmRecord], ['.yarn-integrity', yarnRecord]]
85
+
86
+ // The names pnpm 9 to 12 may give snapshot `key`'s directory in node_modules/.pnpm, as its
87
+ // depPathToFilename (@pnpm/dependency-path) spells them: each character a path can't hold made `+`,
88
+ // `#` too from pnpm 10, and a peer's parentheses `_`; where that is too long or has a capital, cut
89
+ // short before `_` and a hash of it, 26 base32 of an MD5 for pnpm 9, 32 hex of a SHA-256 from pnpm 10,
90
+ // of which the hash alone is kept, as where it is cut depends on the install's
91
+ // virtualStoreDirMaxLength.
92
+ const STORE_NAMES = [
93
+ [/[\\/:*?"<>|]/gu, (name) => toBase32(hash('md5', name, 'buffer'), { padding: false }).toLowerCase()],
94
+ [/[\\/:*?"<>|#]/gu, (name) => hash('sha256', name).slice(0, 32)],
95
+ ]
96
+ const storeNames = (key) => STORE_NAMES.flatMap(([unsafe, digest]) => {
97
+ let name = key.replace(unsafe, '+')
98
+ if (name.includes('(')) name = name.replace(/\)$/u, '').replace(/\)\(|\(|\)/gu, '_')
99
+ return [name, `_${digest(name)}`]
100
+ })
101
+ const STORE_HASH = /_(?:[\da-f]{32}|[a-z2-7]{26})$/u
102
+
103
+ // pnpm's: lock.yaml in `store`, a node_modules/.pnpm, the lockfile of what pnpm 9 to 12 installed
104
+ // there, by the directory there each snapshot is in. A git dependency's resolution is the repository
105
+ // and commit, or GitHub's tarball of the commit, and the package's `path` in it (`#path:`) where it is
106
+ // not at the root.
107
+ function pnpmRecord(store, host) {
108
+ const text = readText(host, join(store, 'lock.yaml'))
109
+ let packages
110
+ try {
111
+ packages = text === null ? undefined : parsePnpmLockfile(text).lockfile?.packages
112
+ } catch {
113
+ return NONE // whatever the reader refuses
114
+ }
115
+ const byName = new Map()
116
+ for (const [key, { resolution }] of Object.entries(packages ?? {})) {
117
+ const commit = resolutionCommit(resolution)
118
+ if (commit === undefined) continue
119
+ const directory = (resolution.path ?? '').replace(/^\/+|\/+$/gu, '')
120
+ const repo = { github: commit.github, ...(isValidRepoField('directory', directory) && { directory }), commit: commit.commit }
121
+ for (const name of storeNames(key)) byName.set(name, repo)
122
+ }
123
+ return (name) => byName.get(name) ?? byName.get(STORE_HASH.exec(name)?.[0])
124
+ }
125
+
126
+ // A pnpm package's real path, <store>/<dir>/node_modules/<name>, its store a node_modules/.pnpm.
127
+ const PNPM_PACKAGE = /^(.*\/node_modules\/\.pnpm)\/([^/]+)\/node_modules\/(?:@[^/]+\/)?[^/]+$/u
128
+
129
+ // Each npm dependency of `bundle`, its buckets' paths from `root`, that is a git dependency of a GitHub
130
+ // repository, recorded as its package manager's record has it installed: that repository, over the
131
+ // one its package.json names (a fork's, where that names the one forked), at its root (`''`) or the
132
+ // subdirectory pnpm records, at that commit. A dependency is found by its real path, through the link
133
+ // pnpm installs it by: in a node_modules/.pnpm, by its directory there; else by its location from the
134
+ // nearest directory whose node_modules npm or yarn laid out, from the one its outermost node_modules
135
+ // below the bundle root is in up to that root, whatever a directory above the root is named: a
136
+ // record outside the root is another project's. Metadata, as `repo` is: never checked against the
137
+ // files installed.
138
+ export function pinInstalledCommits(bundle, root, host = diskHost) {
139
+ const stores = new Map()
140
+ const layouts = new Map()
141
+ let top // the bundle root's real path, as each package's is compared with it
142
+ // The record of `dir`'s node_modules, or null where neither npm nor yarn laid it out; where both
143
+ // left theirs, neither's, as which was last is not known.
144
+ const layoutAt = (dir) => {
145
+ if (!layouts.has(dir)) {
146
+ const found = LAYOUTS.filter(([file]) => host.stat(join(dir, 'node_modules', file)) !== null)
147
+ layouts.set(dir, found.length === 0 ? null : found.length === 1 ? found[0][1](dir, host) : NONE)
148
+ }
149
+ return layouts.get(dir)
150
+ }
151
+ for (const [dir, info] of bundle.modules) {
152
+ if (info.ecosystem !== 'npm') continue
153
+ let real
154
+ try {
155
+ real = toPosix(host.realpath(resolve(root, dir)))
156
+ } catch {
157
+ continue
158
+ }
159
+ let repo
160
+ const pnpm = PNPM_PACKAGE.exec(real)
161
+ if (pnpm !== null) {
162
+ const [, store, name] = pnpm
163
+ if (!stores.has(store)) stores.set(store, pnpmRecord(store, host))
164
+ repo = stores.get(store)(name)
165
+ } else {
166
+ try {
167
+ top ??= resolve(host.realpath(resolve(root)))
168
+ } catch {
169
+ return
170
+ }
171
+ const rel = relative(top, real)
172
+ const parts = toPosix(rel).split('/')
173
+ const at = parts.indexOf('node_modules')
174
+ if (relativeEscapes(rel) || at === -1) continue
175
+ let from = resolve(top, ...parts.slice(0, at))
176
+ while (layoutAt(from) === null && from !== top) from = dirname(from)
177
+ const commit = (layoutAt(from) ?? NONE)(toPosix(relative(from, real)), info.version)
178
+ if (commit !== undefined) repo = { github: commit.github, directory: '', commit: commit.commit }
179
+ }
180
+ if (repo !== undefined) bundle.modules.set(dir, { ...info, repo })
181
+ }
182
+ }
@@ -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)