index.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. /* eslint no-console:0 */
  2. 'use strict'
  3. // Imports
  4. import pathUtil from 'path'
  5. import semver from 'semver'
  6. import { errtion, stringify, simplifyRange } from './util.js'
  7. // As Errlop uses Editions, we should use a specific Errlop edition
  8. // As otherwise, the circular reference may fail on some machines
  9. // https://github.com/bevry/errlop/issues/2
  10. import Errlop from 'errlop'
  11. /**
  12. * The current node version that we are operating within.
  13. * It is compared in {@link requireEdition} against {@link Edition.engines}.
  14. */
  15. const NODE_VERSION: string = process.versions.node
  16. /**
  17. * Set the environment variable `EDITIONS_VERBOSE` to output debugging information to stderr on how editions selected the edition it did.
  18. * Values of `yes` and `true` are supported.
  19. * @example env EDITIONS_VERBOSE=true node mypackage/index.js
  20. */
  21. type EDITIONS_VERBOSE = string | boolean | undefined
  22. /**
  23. * Whether or not {@link EDITIONS_VERBOSE} is enabled.
  24. * @type {bollean}
  25. * @private
  26. */
  27. const VERBOSE: boolean =
  28. (process.env.EDITIONS_VERBOSE as EDITIONS_VERBOSE) === true ||
  29. (process.env.EDITIONS_VERBOSE as EDITIONS_VERBOSE) === 'yes' ||
  30. (process.env.EDITIONS_VERBOSE as EDITIONS_VERBOSE) === 'true' ||
  31. false
  32. /**
  33. * Set the environment variable `EDITIONS_TAG_BLACKLIST` to the tags you wish to blacklist, and editions will skip editions that contain them.
  34. * For backwards compatibility `EDITIONS_SYNTAX_BLACKLIST` is also supported.
  35. * It is compared in {@link requireEdition} against {@link Edition.tags}.
  36. * The value of this is stored locally in the {@link BLACKLIST} cache.
  37. * @example env EDITIONS_TAG_BLACKLIST=esnext,typescript,coffeescript node mypackage/index.js
  38. */
  39. type EDITIONS_TAG_BLACKLIST = string
  40. /**
  41. * A list of the blacklisted tags.
  42. * Data imported from {@link EDITIONS_TAG_BLACKLIST}.
  43. */
  44. const BLACKLIST: string[] =
  45. (process.env.EDITIONS_TAG_BLACKLIST as EDITIONS_TAG_BLACKLIST)?.split(
  46. /[, ]+/g
  47. ) ||
  48. (process.env.EDITIONS_SYNTAX_BLACKLIST as EDITIONS_TAG_BLACKLIST)?.split(
  49. /[, ]+/g
  50. )
  51. /**
  52. * A mapping of blacklisted tags to their reasons.
  53. * Keys are the tags.
  54. * Values are the error instances that contain the reasoning for why/how that tag is/became blacklisted.
  55. * Data imported from {@link EDITIONS_TAG_BLACKLIST}.
  56. */
  57. const blacklist: { [tag: string]: Error } = {}
  58. /** Edition entries must conform to the following specification. */
  59. export interface Edition {
  60. /**
  61. * Use this property to decribe the edition in human readable terms. Such as what it does and who it is for. It is used to reference the edition in user facing reporting, such as error messages.
  62. * @example "esnext source code with require for modules"
  63. */
  64. description: String
  65. /**
  66. * The location to where this directory is located. It should be a relative path from the `package.json` file.
  67. * @example "source"
  68. */
  69. directory: string
  70. /**
  71. * The default entry location for this edition, relative to the edition's directory.
  72. * @example "index.js"
  73. */
  74. entry?: string
  75. /**
  76. * Any keywords you wish to associate to the edition. Useful for various ecosystem tooling, such as automatic ESNext lint configuration if the `esnext` tag is present in the source edition tags. Consumers also make use of this via {@link EDITIONS_TAG_BLACKLIST} for preventing loading editions that contain a blacklisted tag. Previously this field was named `syntaxes`.
  77. * @example ["javascript", "esnext", "require"]
  78. */
  79. tags?: string[]
  80. /** @alias tags */
  81. syntaxes?: Edition['tags']
  82. /**
  83. * This field is used to specific which Node.js and Browser environments this edition supports. If `false` this edition does not support either. If `node` is a string, it should be a semver range of node.js versions that the edition targets. If `browsers` is a string, it should be a [browserlist](https://github.com/browserslist/browserslist) value of the specific browser values the edition targets. If `node` or `browsers` is true, it indicates that this edition is compatible with those environments.
  84. * @example
  85. * {
  86. * "description": "esnext source code with require for modules",
  87. * "directory": "source",
  88. * "entry": "index.js",
  89. * "tags": [
  90. * "javascript",
  91. * "esnext",
  92. * "require"
  93. * ],
  94. * "engines": {
  95. * "node": ">=6",
  96. * "browsers": "defaults"
  97. * }
  98. * }
  99. */
  100. engines?: false | { [engine: string]: string | boolean }
  101. }
  102. /** These are the various options that you can use to customise the behaviour of certain methods. */
  103. export interface EditionOptions {
  104. /** The require method of the calling module, used to ensure require paths remain correct. */
  105. require: Function
  106. /** If provided, this is used for debugging. */
  107. packagePath?: string
  108. /** If provided, any error loading an edition will be logged. By default, errors are only logged if all editions failed. If not provided, process.env.EDITIONS_VERBOSE is used. */
  109. verbose?: boolean
  110. /** If `true`, then only exact version matches will be loaded. If `false`, then likely matches using {@link simplifyRange} will be evaluated, with a fallback to the last. If missing, then `true` is attempted first and if no result, then `false` is attempted. */
  111. strict?: boolean
  112. /** If provided, this will be the cwd for entries. */
  113. cwd?: string
  114. /** If provided, should be a relative path to the entry point of the edition. */
  115. entry?: string
  116. /** If provided, should be the name of the package that we are loading the editions for. */
  117. package?: string
  118. /** If not provided, will use process.stderr instead. It is the stream that verbose errors are logged to. */
  119. stderr?: NodeJS.WritableStream
  120. }
  121. // Create the mapping of blacklisted tags and their reasonings
  122. if (BLACKLIST) {
  123. for (let i = 0; i < BLACKLIST.length; ++i) {
  124. const tag = BLACKLIST[i].trim().toLowerCase()
  125. blacklist[tag] = errtion({
  126. message: `The EDITIONS_TAG_BLACKLIST (aka EDITIONS_SYNTAX_BLACKLIST) environment variable blacklisted the tag [${tag}]`,
  127. code: 'blacklisted-tag'
  128. })
  129. }
  130. }
  131. // Blacklist the tag 'esnext' if our node version is below 0.12
  132. if (semver.satisfies(NODE_VERSION, '<0.12')) {
  133. blacklist.esnext = new Error(
  134. 'The esnext tag is skipped on early node versions as attempting to use esnext features will output debugging information on these node versions'
  135. )
  136. }
  137. /**
  138. * Attempt to load a specific {@link Edition}.
  139. * @returns The result of the loaded edition.
  140. * @throws An error if the edition failed to load.
  141. */
  142. export function loadEdition(edition: Edition, opts: EditionOptions): any {
  143. const entry = pathUtil.resolve(
  144. opts.cwd || '',
  145. edition.directory,
  146. opts.entry || edition.entry || ''
  147. )
  148. if (opts.require == null) {
  149. throw errtion({
  150. message: `Skipped edition [${edition.description}] as opts.require was not provided, this is probably due to a testing misconfiguration.`,
  151. code: 'unsupported-edition-require'
  152. })
  153. }
  154. try {
  155. return opts.require(entry)
  156. } catch (loadError) {
  157. // Note the error with more details
  158. throw errtion(
  159. {
  160. message: `Skipped edition [${edition.description}] at entry [${entry}] because it failed to load`,
  161. code: 'unsupported-edition-tried'
  162. },
  163. loadError
  164. )
  165. }
  166. }
  167. /**
  168. * Attempt to require an {@link Edition}, based on its compatibility with the current environment, such as {@link NODE_VERSION} and {@link EDITIONS_TAG_BLACKLIST} compatibility.
  169. * If compatibility is established with the environment, it will load the edition using {@link loadEdition}.
  170. * @returns The result of the loaded edition
  171. * @throws An error if the edition failed to load
  172. */
  173. export function requireEdition(edition: Edition, opts: EditionOptions): any {
  174. // Verify the edition is valid
  175. if (
  176. !edition.description ||
  177. !edition.directory ||
  178. !edition.entry ||
  179. edition.engines == null
  180. ) {
  181. throw errtion({
  182. message: `Each edition must have its [description, directory, entry, engines] fields defined, yet all it had was [${Object.keys(
  183. edition
  184. ).join(', ')}]`,
  185. code: 'unsupported-edition-malformed',
  186. level: 'fatal'
  187. })
  188. }
  189. // Handle strict omission
  190. if (opts.strict == null) {
  191. try {
  192. return requireEdition(edition, { ...opts, strict: true })
  193. } catch (err) {
  194. return requireEdition(edition, { ...opts, strict: false })
  195. }
  196. }
  197. // Verify tag support
  198. // Convert tags into a sorted lowercase string
  199. const tags = (edition.tags || edition.syntaxes || [])
  200. .map(i => i.toLowerCase())
  201. .sort()
  202. for (let index = 0; index < tags.length; index++) {
  203. const tag = tags[index]
  204. const blacklisted = blacklist[tag]
  205. if (blacklisted) {
  206. throw errtion(
  207. {
  208. message: `Skipping edition [${edition.description}] because it contained a blacklisted tag [${tag}]`,
  209. code: 'unsupported-edition-backlisted-tag'
  210. },
  211. blacklisted
  212. )
  213. }
  214. }
  215. // Verify engine support
  216. if (edition.engines === false) {
  217. throw errtion({
  218. message: `Skipping edition [${edition.description}] because its engines field was false`,
  219. code: 'unsupported-edition-engine'
  220. })
  221. }
  222. if (!edition.engines.node) {
  223. throw errtion({
  224. message: `Skipping edition [${edition.description}] because its .engines.node field was falsey`,
  225. code: 'unsupported-edition-engines-node'
  226. })
  227. }
  228. if (opts.strict) {
  229. if (edition.engines.node === true) {
  230. throw errtion({
  231. message: `Skipping edition [${edition.description}] because its .engines.node field was true yet we are in strict mode`,
  232. code: 'unsupported-edition-engines-node-version-true'
  233. })
  234. } else if (semver.satisfies(NODE_VERSION, edition.engines.node) === false) {
  235. throw errtion({
  236. message: `Skipping edition [${
  237. edition.description
  238. }] because our current node version [${NODE_VERSION}] is not supported by its specific range [${stringify(
  239. edition.engines.node
  240. )}]`,
  241. code: 'unsupported-edition-engines-node-version-specific'
  242. })
  243. }
  244. } else if (edition.engines.node !== true) {
  245. const simplifiedRange = simplifyRange(edition.engines.node)
  246. if (semver.satisfies(NODE_VERSION, simplifiedRange) === false) {
  247. throw errtion({
  248. message: `Skipping edition [${
  249. edition.description
  250. }] because our current node version [${NODE_VERSION}] is not supported by its simplified range [${stringify(
  251. simplifiedRange
  252. )}]`,
  253. code: 'unsupported-edition-engines-node-version-simplified'
  254. })
  255. }
  256. }
  257. // Load the edition
  258. return loadEdition(edition, opts)
  259. }
  260. /**
  261. * Cycles through a list of editions, returning the require result of the first suitable {@link Edition} that it was able to load.
  262. * Editions should be ordered from most preferable first, to least desirable last.
  263. * Providing the editions configuration is valid, individual edition handling is forwarded to {@link requireEdition}.
  264. * @returns The result of the loaded edition.
  265. * @throws An error if a suitable edition was unable to be resolved.
  266. */
  267. export function requireEditions(
  268. editions: Edition[],
  269. opts: EditionOptions
  270. ): any {
  271. // Check
  272. if (!editions || editions.length === 0) {
  273. if (opts.packagePath) {
  274. throw errtion({
  275. message: `There were no editions specified for package [${opts.packagePath}]`,
  276. code: 'unsupported-editions-missing'
  277. })
  278. } else {
  279. throw errtion({
  280. message: 'There were no editions specified',
  281. code: 'unsupported-editions-missing'
  282. })
  283. }
  284. }
  285. // Handle strict omission
  286. if (opts.strict == null) {
  287. try {
  288. return requireEditions(editions, { ...opts, strict: true })
  289. } catch (err) {
  290. return requireEditions(editions, { ...opts, strict: false })
  291. }
  292. }
  293. // Whether or not we should be verbose
  294. const verbose = opts.verbose == null ? VERBOSE : opts.verbose
  295. // Capture the load result, the last error, and the fallback option
  296. let result,
  297. loaded = false,
  298. editionsError = null,
  299. fallbackEdition = null
  300. // Cycle through the editions determing the above
  301. for (let i = 0; i < editions.length; ++i) {
  302. const edition = editions[i]
  303. try {
  304. result = requireEdition(edition, opts)
  305. loaded = true
  306. break
  307. } catch (editionError) {
  308. if (editionError.level === 'fatal') {
  309. editionsError = editionError
  310. break
  311. } else if (editionsError) {
  312. editionsError = errtion(editionsError, editionError)
  313. } else {
  314. editionsError = editionError
  315. }
  316. // make the fallback edition one that we don't bother loading due to its engines
  317. // also: don't assume that .code is accessible, as it may not be, even if it should be, due to the way different environments behave
  318. if (
  319. String(editionError.code || '').indexOf(
  320. 'unsupported-edition-engines-node-version'
  321. ) === 0
  322. ) {
  323. fallbackEdition = edition
  324. }
  325. }
  326. }
  327. // if no edition was suitable for our environment, then try the fallback if it exists
  328. // that is to say, ignore its engines.node
  329. if (opts.strict === false && loaded === false && fallbackEdition) {
  330. try {
  331. result = loadEdition(fallbackEdition, opts)
  332. loaded = true
  333. } catch (editionError) {
  334. editionsError = new Errlop(editionError, editionsError)
  335. }
  336. }
  337. // if we were able to load something, then provide it
  338. if (loaded) {
  339. // make note of any errors if desired
  340. if (editionsError && verbose) {
  341. const stderr = opts.stderr || process.stderr
  342. stderr.write(editionsError.stack + '\n')
  343. }
  344. return result
  345. }
  346. // otherwise, provide the error
  347. else if (editionsError) {
  348. if (opts.packagePath) {
  349. throw errtion(
  350. {
  351. message: `There were no suitable editions for package [${opts.packagePath}]`,
  352. code: 'unsupported-editions-tried'
  353. },
  354. editionsError
  355. )
  356. } else {
  357. throw errtion(
  358. {
  359. message: 'There were no suitable editions',
  360. code: 'unsupported-editions-tried'
  361. },
  362. editionsError
  363. )
  364. }
  365. }
  366. }
  367. /**
  368. * Cycle through the editions for a package and require the correct one.
  369. * Providing the package configuration is valid, editions handling is forwarded to {@link requireEditions}.
  370. * @returns The result of the loaded edition.
  371. * @throws An error if a suitable edition was unable to be resolved.
  372. */
  373. export function requirePackage(
  374. cwd: EditionOptions['cwd'],
  375. require: EditionOptions['require'],
  376. entry: EditionOptions['entry']
  377. ): any {
  378. // Load the package.json file to fetch `name` for debugging and `editions` for loading
  379. const packagePath = pathUtil.resolve(cwd || '', 'package.json')
  380. const { editions } = require(packagePath)
  381. const opts = { packagePath, cwd, require, entry }
  382. return requireEditions(editions, opts)
  383. }