index.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. 'use strict'
  2. const { Walker: IgnoreWalker } = require('ignore-walk')
  3. const { lstatSync: lstat, readFileSync: readFile } = require('fs')
  4. const { basename, dirname, extname, join, relative, resolve, sep } = require('path')
  5. // symbols used to represent synthetic rule sets
  6. const defaultRules = Symbol('npm-packlist.rules.default')
  7. const strictRules = Symbol('npm-packlist.rules.strict')
  8. // There may be others, but :?|<> are handled by node-tar
  9. const nameIsBadForWindows = file => /\*/.test(file)
  10. // these are the default rules that are applied to everything except for non-link bundled deps
  11. const defaults = [
  12. '.npmignore',
  13. '.gitignore',
  14. '**/.git',
  15. '**/.svn',
  16. '**/.hg',
  17. '**/CVS',
  18. '**/.git/**',
  19. '**/.svn/**',
  20. '**/.hg/**',
  21. '**/CVS/**',
  22. '/.lock-wscript',
  23. '/.wafpickle-*',
  24. '/build/config.gypi',
  25. 'npm-debug.log',
  26. '**/.npmrc',
  27. '.*.swp',
  28. '.DS_Store',
  29. '**/.DS_Store/**',
  30. '._*',
  31. '**/._*/**',
  32. '*.orig',
  33. '/archived-packages/**',
  34. ]
  35. const strictDefaults = [
  36. // these are forcibly excluded
  37. '/.git',
  38. ]
  39. const normalizePath = (path) => path.split('\\').join('/')
  40. const readOutOfTreeIgnoreFiles = (root, rel, result = []) => {
  41. for (const file of ['.npmignore', '.gitignore']) {
  42. try {
  43. const ignoreContent = readFile(join(root, file), { encoding: 'utf8' })
  44. result.push(ignoreContent)
  45. // break the loop immediately after reading, this allows us to prioritize
  46. // the .npmignore and discard the .gitignore if one is present
  47. break
  48. } catch (err) {
  49. // we ignore ENOENT errors completely because we don't care if the file doesn't exist
  50. // but we throw everything else because failing to read a file that does exist is
  51. // something that the user likely wants to know about
  52. // istanbul ignore next -- we do not need to test a thrown error
  53. if (err.code !== 'ENOENT') {
  54. throw err
  55. }
  56. }
  57. }
  58. if (!rel) {
  59. return result
  60. }
  61. const firstRel = rel.split(sep, 1)[0]
  62. const newRoot = join(root, firstRel)
  63. const newRel = relative(newRoot, join(root, rel))
  64. return readOutOfTreeIgnoreFiles(newRoot, newRel, result)
  65. }
  66. class PackWalker extends IgnoreWalker {
  67. constructor (tree, opts) {
  68. const options = {
  69. ...opts,
  70. includeEmpty: false,
  71. follow: false,
  72. // we path.resolve() here because ignore-walk doesn't do it and we want full paths
  73. path: resolve(opts?.path || tree.path).replace(/\\/g, '/'),
  74. ignoreFiles: opts?.ignoreFiles || [
  75. defaultRules,
  76. 'package.json',
  77. '.npmignore',
  78. '.gitignore',
  79. strictRules,
  80. ],
  81. }
  82. super(options)
  83. this.isPackage = options.isPackage
  84. this.seen = options.seen || new Set()
  85. this.tree = tree
  86. this.requiredFiles = options.requiredFiles || []
  87. const additionalDefaults = []
  88. if (options.prefix && options.workspaces) {
  89. const path = normalizePath(options.path)
  90. const prefix = normalizePath(options.prefix)
  91. const workspaces = options.workspaces.map((ws) => normalizePath(ws))
  92. // istanbul ignore else - this does nothing unless we need it to
  93. if (path !== prefix && workspaces.includes(path)) {
  94. // if path and prefix are not the same directory, and workspaces has path in it
  95. // then we know path is a workspace directory. in order to not drop ignore rules
  96. // from directories between the workspaces root (prefix) and the workspace itself
  97. // (path) we need to find and read those now
  98. const relpath = relative(options.prefix, dirname(options.path))
  99. additionalDefaults.push(...readOutOfTreeIgnoreFiles(options.prefix, relpath))
  100. } else if (path === prefix) {
  101. // on the other hand, if the path and prefix are the same, then we ignore workspaces
  102. // so that we don't pack a workspace as part of the root project. append them as
  103. // normalized relative paths from the root
  104. additionalDefaults.push(...workspaces.map((w) => normalizePath(relative(options.path, w))))
  105. }
  106. }
  107. // go ahead and inject the default rules now
  108. this.injectRules(defaultRules, [...defaults, ...additionalDefaults])
  109. if (!this.isPackage) {
  110. // if this instance is not a package, then place some strict default rules, and append
  111. // known required files for this directory
  112. this.injectRules(strictRules, [
  113. ...strictDefaults,
  114. ...this.requiredFiles.map((file) => `!${file}`),
  115. ])
  116. }
  117. }
  118. // overridden method: we intercept the reading of the package.json file here so that we can
  119. // process it into both the package.json file rules as well as the strictRules synthetic rule set
  120. addIgnoreFile (file, callback) {
  121. // if we're adding anything other than package.json, then let ignore-walk handle it
  122. if (file !== 'package.json' || !this.isPackage) {
  123. return super.addIgnoreFile(file, callback)
  124. }
  125. return this.processPackage(callback)
  126. }
  127. // overridden method: if we're done, but we're a package, then we also need to evaluate bundles
  128. // before we actually emit our done event
  129. emit (ev, data) {
  130. if (ev !== 'done' || !this.isPackage) {
  131. return super.emit(ev, data)
  132. }
  133. // we intentionally delay the done event while keeping the function sync here
  134. // eslint-disable-next-line promise/catch-or-return, promise/always-return
  135. this.gatherBundles().then(() => {
  136. super.emit('done', this.result)
  137. })
  138. return true
  139. }
  140. // overridden method: before actually filtering, we make sure that we've removed the rules for
  141. // files that should no longer take effect due to our order of precedence
  142. filterEntries () {
  143. if (this.ignoreRules['package.json']) {
  144. // package.json means no .npmignore or .gitignore
  145. this.ignoreRules['.npmignore'] = null
  146. this.ignoreRules['.gitignore'] = null
  147. } else if (this.ignoreRules['.npmignore']) {
  148. // .npmignore means no .gitignore
  149. this.ignoreRules['.gitignore'] = null
  150. }
  151. return super.filterEntries()
  152. }
  153. // overridden method: we never want to include anything that isn't a file or directory
  154. onstat (opts, callback) {
  155. if (!opts.st.isFile() && !opts.st.isDirectory()) {
  156. return callback()
  157. }
  158. return super.onstat(opts, callback)
  159. }
  160. // overridden method: we want to refuse to pack files that are invalid, node-tar protects us from
  161. // a lot of them but not all
  162. stat (opts, callback) {
  163. if (nameIsBadForWindows(opts.entry)) {
  164. return callback()
  165. }
  166. return super.stat(opts, callback)
  167. }
  168. // overridden method: this is called to create options for a child walker when we step
  169. // in to a normal child directory (this will never be a bundle). the default method here
  170. // copies the root's `ignoreFiles` value, but we don't want to respect package.json for
  171. // subdirectories, so we override it with a list that intentionally omits package.json
  172. walkerOpt (entry, opts) {
  173. let ignoreFiles = null
  174. // however, if we have a tree, and we have workspaces, and the directory we're about
  175. // to step into is a workspace, then we _do_ want to respect its package.json
  176. if (this.tree.workspaces) {
  177. const workspaceDirs = [...this.tree.workspaces.values()]
  178. .map((dir) => dir.replace(/\\/g, '/'))
  179. const entryPath = join(this.path, entry).replace(/\\/g, '/')
  180. if (workspaceDirs.includes(entryPath)) {
  181. ignoreFiles = [
  182. defaultRules,
  183. 'package.json',
  184. '.npmignore',
  185. '.gitignore',
  186. strictRules,
  187. ]
  188. }
  189. } else {
  190. ignoreFiles = [
  191. defaultRules,
  192. '.npmignore',
  193. '.gitignore',
  194. strictRules,
  195. ]
  196. }
  197. return {
  198. ...super.walkerOpt(entry, opts),
  199. ignoreFiles,
  200. // we map over our own requiredFiles and pass ones that are within this entry
  201. requiredFiles: this.requiredFiles
  202. .map((file) => {
  203. if (relative(file, entry) === '..') {
  204. return relative(entry, file).replace(/\\/g, '/')
  205. }
  206. return false
  207. })
  208. .filter(Boolean),
  209. }
  210. }
  211. // overridden method: we want child walkers to be instances of this class, not ignore-walk
  212. walker (entry, opts, callback) {
  213. new PackWalker(this.tree, this.walkerOpt(entry, opts)).on('done', callback).start()
  214. }
  215. // overridden method: we use a custom sort method to help compressibility
  216. sort (a, b) {
  217. // optimize for compressibility
  218. // extname, then basename, then locale alphabetically
  219. // https://twitter.com/isntitvacant/status/1131094910923231232
  220. const exta = extname(a).toLowerCase()
  221. const extb = extname(b).toLowerCase()
  222. const basea = basename(a).toLowerCase()
  223. const baseb = basename(b).toLowerCase()
  224. return exta.localeCompare(extb, 'en') ||
  225. basea.localeCompare(baseb, 'en') ||
  226. a.localeCompare(b, 'en')
  227. }
  228. // convenience method: this joins the given rules with newlines, appends a trailing newline,
  229. // and calls the internal onReadIgnoreFile method
  230. injectRules (filename, rules, callback = () => {}) {
  231. this.onReadIgnoreFile(filename, `${rules.join('\n')}\n`, callback)
  232. }
  233. // custom method: this is called by addIgnoreFile when we find a package.json, it uses the
  234. // arborist tree to pull both default rules and strict rules for the package
  235. processPackage (callback) {
  236. const {
  237. bin,
  238. browser,
  239. files,
  240. main,
  241. } = this.tree.package
  242. // rules in these arrays are inverted since they are patterns we want to _not_ ignore
  243. const ignores = []
  244. const strict = [
  245. ...strictDefaults,
  246. '!/package.json',
  247. '!/readme{,.*[^~$]}',
  248. '!/copying{,.*[^~$]}',
  249. '!/license{,.*[^~$]}',
  250. '!/licence{,.*[^~$]}',
  251. '/.git',
  252. '/node_modules',
  253. '.npmrc',
  254. '/package-lock.json',
  255. '/yarn.lock',
  256. '/pnpm-lock.yaml',
  257. ]
  258. // if we have a files array in our package, we need to pull rules from it
  259. if (files) {
  260. for (let file of files) {
  261. // invert the rule because these are things we want to include
  262. if (file.startsWith('./')) {
  263. file = file.slice(1)
  264. }
  265. if (file.endsWith('/*')) {
  266. file += '*'
  267. }
  268. const inverse = `!${file}`
  269. try {
  270. // if an entry in the files array is a specific file, then we need to include it as a
  271. // strict requirement for this package. if it's a directory or a pattern, it's a default
  272. // pattern instead. this is ugly, but we have to stat to find out if it's a file
  273. const stat = lstat(join(this.path, file.replace(/^!+/, '')).replace(/\\/g, '/'))
  274. // if we have a file and we know that, it's strictly required
  275. if (stat.isFile()) {
  276. strict.unshift(inverse)
  277. this.requiredFiles.push(file.startsWith('/') ? file.slice(1) : file)
  278. } else if (stat.isDirectory()) {
  279. // otherwise, it's a default ignore, and since we got here we know it's not a pattern
  280. // so we include the directory contents
  281. ignores.push(inverse)
  282. ignores.push(`${inverse}/**`)
  283. }
  284. // if the thing exists, but is neither a file or a directory, we don't want it at all
  285. } catch (err) {
  286. // if lstat throws, then we assume we're looking at a pattern and treat it as a default
  287. ignores.push(inverse)
  288. }
  289. }
  290. // we prepend a '*' to exclude everything, followed by our inverted file rules
  291. // which now mean to include those
  292. this.injectRules('package.json', ['*', ...ignores])
  293. }
  294. // browser is required
  295. if (browser) {
  296. strict.push(`!/${browser}`)
  297. }
  298. // main is required
  299. if (main) {
  300. strict.push(`!/${main}`)
  301. }
  302. // each bin is required
  303. if (bin) {
  304. for (const key in bin) {
  305. strict.push(`!/${bin[key]}`)
  306. }
  307. }
  308. // and now we add all of the strict rules to our synthetic file
  309. this.injectRules(strictRules, strict, callback)
  310. }
  311. // custom method: after we've finished gathering the files for the root package, we call this
  312. // before emitting the 'done' event in order to gather all of the files for bundled deps
  313. async gatherBundles () {
  314. if (this.seen.has(this.tree)) {
  315. return
  316. }
  317. // add this node to our seen tracker
  318. this.seen.add(this.tree)
  319. // if we're the project root, then we look at our bundleDependencies, otherwise we got here
  320. // because we're a bundled dependency of the root, which means we need to include all prod
  321. // and optional dependencies in the bundle
  322. let toBundle
  323. if (this.tree.isProjectRoot) {
  324. const { bundleDependencies } = this.tree.package
  325. toBundle = bundleDependencies || []
  326. } else {
  327. const { dependencies, optionalDependencies } = this.tree.package
  328. toBundle = Object.keys(dependencies || {}).concat(Object.keys(optionalDependencies || {}))
  329. }
  330. for (const dep of toBundle) {
  331. const edge = this.tree.edgesOut.get(dep)
  332. // no edgeOut = missing node, so skip it. we can't pack it if it's not here
  333. // we also refuse to pack peer dependencies and dev dependencies
  334. if (!edge || edge.peer || edge.dev) {
  335. continue
  336. }
  337. // get a reference to the node we're bundling
  338. const node = this.tree.edgesOut.get(dep).to
  339. // if there's no node, this is most likely an optional dependency that hasn't been
  340. // installed. just skip it.
  341. if (!node) {
  342. continue
  343. }
  344. // we use node.path for the path because we want the location the node was linked to,
  345. // not where it actually lives on disk
  346. const path = node.path
  347. // but link nodes don't have edgesOut, so we need to pass in the target of the node
  348. // in order to make sure we correctly traverse its dependencies
  349. const tree = node.target
  350. // and start building options to be passed to the walker for this package
  351. const walkerOpts = {
  352. path,
  353. isPackage: true,
  354. ignoreFiles: [],
  355. seen: this.seen, // pass through seen so we can prevent infinite circular loops
  356. }
  357. // if our node is a link, we apply defaultRules. we don't do this for regular bundled
  358. // deps because their .npmignore and .gitignore files are excluded by default and may
  359. // override defaults
  360. if (node.isLink) {
  361. walkerOpts.ignoreFiles.push(defaultRules)
  362. }
  363. // _all_ nodes will follow package.json rules from their package root
  364. walkerOpts.ignoreFiles.push('package.json')
  365. // only link nodes will obey .npmignore or .gitignore
  366. if (node.isLink) {
  367. walkerOpts.ignoreFiles.push('.npmignore')
  368. walkerOpts.ignoreFiles.push('.gitignore')
  369. }
  370. // _all_ nodes follow strict rules
  371. walkerOpts.ignoreFiles.push(strictRules)
  372. // create a walker for this dependency and gather its results
  373. const walker = new PackWalker(tree, walkerOpts)
  374. const bundled = await new Promise((pResolve, pReject) => {
  375. walker.on('error', pReject)
  376. walker.on('done', pResolve)
  377. walker.start()
  378. })
  379. // now we make sure we have our paths correct from the root, and accumulate everything into
  380. // our own result set to deduplicate
  381. const relativeFrom = relative(this.root, walker.path)
  382. for (const file of bundled) {
  383. this.result.add(join(relativeFrom, file).replace(/\\/g, '/'))
  384. }
  385. }
  386. }
  387. }
  388. const walk = (tree, options, callback) => {
  389. if (typeof options === 'function') {
  390. callback = options
  391. options = {}
  392. }
  393. const p = new Promise((pResolve, pReject) => {
  394. new PackWalker(tree, { ...options, isPackage: true })
  395. .on('done', pResolve).on('error', pReject).start()
  396. })
  397. return callback ? p.then(res => callback(null, res), callback) : p
  398. }
  399. module.exports = walk
  400. walk.Walker = PackWalker