UNPKG

15.8 kBPlain TextView Raw
1// Imports
2import { resolve } from 'path'
3import matchRange from 'version-range'
4import { errtion, Errtion } from './util.js'
5import { versions as processVersions } from 'process'
6import { readFileSync } from 'fs'
7
8export type Range = string | boolean
9export type Engines = false | { [engine: string]: Range }
10export type Versions = { [engine: string]: string }
11
12/**
13 * Edition entries must conform to the following specification.
14 * @example
15 * ``` json
16 * {
17 * "description": "esnext source code with require for modules",
18 * "directory": "source",
19 * "entry": "index.js",
20 * "tags": [
21 * "javascript",
22 * "esnext",
23 * "require"
24 * ],
25 * "engines": {
26 * "node": ">=6",
27 * "browsers": "defaults"
28 * }
29 * }
30 * ```
31 */
32export interface Edition {
33 /**
34 * Use this property to describe 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.
35 * @example
36 * ``` json
37 * "esnext source code with require for modules"
38 * ```
39 */
40 description: string
41
42 /**
43 * The location to where this directory is located. It should be a relative path from the `package.json` file.
44 * @example
45 * ``` json
46 * "source"
47 * ```
48 */
49 directory: string
50
51 /**
52 * The default entry location for this edition, relative to the edition's directory.
53 * @example
54 * ``` json
55 * "index.js"
56 * ```
57 */
58 entry: string
59
60 /**
61 * 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.
62 * @example
63 * ``` json
64 * ["javascript", "esnext", "require"]
65 * ```
66 */
67 tags?: string[]
68
69 /**
70 * This field is used to specific which environments this edition supports.
71 * If `false` this edition does not any environment.
72 * If `deno` is a string, it should be a semver range of Deno versions that the edition targets.
73 * If `node` is a string, it should be a semver range of Node.js versions that the edition targets.
74 * 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 multiple engines are truthy, it indicates that this edition is compatible with those multiple environments.
75 * @example
76 * ``` json
77 * {
78 * "deno": ">=1",
79 * "node": ">=6",
80 * "browsers": "defaults"
81 * }
82 * ```
83 */
84 engines: Engines
85
86 /** If this edition fails to load, then this property provides any accompanying information. */
87 debugging?: Errtion
88}
89
90/** Editions should be ordered from most preferable first to least desirable last. The source edition should always be first, proceeded by compiled editions. */
91export type Editions = Array<Edition>
92
93export interface PathOptions {
94 /** If provided, this edition entry is used instead of the default entry. */
95 entry: string
96
97 /** If provided, edition loading will be resolved against this. */
98 cwd: string
99}
100
101export interface LoaderOptions extends Partial<PathOptions> {
102 /**
103 * The method that will load the entry of the edition.
104 * For CJS files this should be set to the `require` method.
105 * For MJS files this should be set to `(path: string) => import(path)`.
106 */
107 loader: <T>(this: Edition, path: string) => T
108}
109
110export interface RangeOptions {
111 /** If `true`, then ranges such as `x || y` are changed to `>=x`. */
112 broadenRange?: boolean
113}
114
115export interface VersionOptions extends RangeOptions {
116 /** The versions of our current environment. */
117 versions: Versions
118}
119
120export interface SolicitOptions extends LoaderOptions, VersionOptions {}
121
122/**
123 * Load the {@link Edition} with the loader.
124 * @returns The result of the loaded edition.
125 * @throws If failed to load, an error is thrown with the reason.
126 */
127export function loadEdition<T>(edition: Edition, opts: LoaderOptions): T {
128 const entry = resolve(
129 opts.cwd || '',
130 edition.directory,
131 opts.entry || edition.entry || ''
132 )
133
134 if (opts.loader == null) {
135 throw errtion({
136 message: `Could not load the edition [${edition.description}] as no loader was specified. This is probably due to a testing misconfiguration.`,
137 code: 'editions-autoloader-loader-missing',
138 level: 'fatal',
139 })
140 }
141
142 try {
143 return opts.loader.call(edition, entry) as T
144 } catch (loadError) {
145 // Note the error with more details
146 throw errtion(
147 {
148 message: `Failed to load the entry [${entry}] of edition [${edition.description}].`,
149 code: 'editions-autoloader-loader-failed',
150 level: 'fatal',
151 },
152 loadError
153 )
154 }
155}
156
157/**
158 * Verify the {@link Edition} has all the required properties.
159 * @returns if valid
160 * @throws if invalid
161 */
162export function isValidEdition(edition: Edition): true {
163 if (
164 !edition.description ||
165 !edition.directory ||
166 !edition.entry ||
167 edition.engines == null
168 ) {
169 throw errtion({
170 message: `An edition must have its [description, directory, entry, engines] fields defined, yet all this edition defined were [${Object.keys(
171 edition
172 ).join(', ')}]`,
173 code: 'editions-autoloader-invalid-edition',
174 level: 'fatal',
175 })
176 }
177
178 // valid
179 return true
180}
181
182/**
183 * Is this {@link Edition} suitable for these versions?
184 * @returns if compatible
185 * @throws if incompatible
186 */
187export function isCompatibleVersion(
188 range: Range,
189 version: string,
190 opts: RangeOptions
191): true {
192 // prepare
193 const { broadenRange } = opts
194
195 if (!version)
196 throw errtion({
197 message: `No version was specified to compare the range [${range}] against`,
198 code: 'editions-autoloader-engine-version-missing',
199 level: 'fatal',
200 })
201
202 if (range == null || range === '')
203 throw errtion({
204 message: `The edition range was not specified, so unable to compare against the version [${version}]`,
205 code: 'editions-autoloader-engine-range-missing',
206 })
207
208 if (range === false)
209 throw errtion({
210 message: `The edition range does not support this engine`,
211 code: 'editions-autoloader-engine-unsupported',
212 })
213
214 if (range === true) return true
215
216 // original range
217 try {
218 if (matchRange(version, range)) return true
219 } catch (error) {
220 throw errtion(
221 {
222 message: `The range [${range}] was invalid, something is wrong with the Editions definition.`,
223 code: 'editions-autoloader-invalid-range',
224 level: 'fatal',
225 },
226 error
227 )
228 }
229
230 // broadened range
231 // https://github.com/bevry/editions/blob/master/HISTORY.md#v210-2018-november-15
232 // If none of the editions for a package match the current node version, editions will try to find a compatible package by converting strict version ranges likes 4 || 6 || 8 || 10 to looser ones like >=4, and if that fails, then it will attempt to load the last edition for the environment.
233 // This brings editions handling of engines closer in line with how node handles it, which is as a warning/recommendation, rather than a requirement/enforcement.
234 // This has the benefit that edition authors can specify ranges as the specific versions that they have tested the edition against that pass, rather than having to omit that information for runtime compatibility.
235 // As such editions will now automatically select the edition with guaranteed support for the environment, and if there are none with guaranteed support, then editions will select the one is most likely supported, and if there are none that are likely supported, then it will try the last edition, which should be the most compatible edition.
236 // This is timely, as node v11 is now the version most developers use, yet if edition authors specified only LTS releases, then the editions autoloader would reject loading on v11, despite compatibility being likely with the most upper edition.
237 // NOTE: That there is only one broadening chance per package, once a broadened edition has been returned, a load will be attempted, and if it fails, then the package failed. This is intentional.
238 if (broadenRange === true) {
239 // check if range can be broadened, validate it and extract
240 const broadenedRangeRegex = /^\s*([0-9.]+)\s*(\|\|\s*[0-9.]+\s*)*$/
241 const broadenedRangeMatch = range.match(broadenedRangeRegex)
242 const lowestVersion: string =
243 (broadenedRangeMatch && broadenedRangeMatch[1]) || ''
244 // ^ can't do number conversion, as 1.1.1 is not a number
245 // this also converts 0 to '' which is what we want for the next check
246
247 // confirm the validation
248 if (lowestVersion === '')
249 throw errtion({
250 message: `The range [${range}] is not able to be broadened, only ranges in format of [lowest] or [lowest || ... || ... ] can be broadened. Update the Editions definition and try again.`,
251 code: 'editions-autoloader-unsupported-broadened-range',
252 level: 'fatal',
253 })
254
255 // create the broadened range, and attempt that
256 const broadenedRange = `>= ${lowestVersion}`
257 try {
258 if (matchRange(version, broadenedRange)) return true
259 } catch (error) {
260 throw errtion(
261 {
262 message: `The broadened range [${broadenedRange}] was invalid, something is wrong within Editions.`,
263 code: 'editions-autoloader-invalid-broadened-range',
264 level: 'fatal',
265 },
266 error
267 )
268 }
269
270 // broadened range was incompatible
271 throw errtion({
272 message: `The edition range [${range}] does not support this engine version [${version}], even when broadened to [${broadenedRange}]`,
273 code: 'editions-autoloader-engine-incompatible-broadened-range',
274 })
275 }
276
277 // give up
278 throw errtion({
279 message: `The edition range [${range}] does not support this engine version [${version}]`,
280 code: 'editions-autoloader-engine-incompatible-original',
281 })
282}
283
284/**
285 * Checks that the provided engines are compatible against the provided versions.
286 * @returns if compatible
287 * @throws if incompatible
288 */
289export function isCompatibleEngines(
290 engines: Engines,
291 opts: VersionOptions
292): true {
293 // PRepare
294 const { versions } = opts
295
296 // Check engines exist
297 if (!engines) {
298 throw errtion({
299 message: `The edition had no engines to compare against the environment`,
300 code: 'editions-autoloader-invalid-engines',
301 })
302 }
303
304 // Check versions exist
305 if (!versions) {
306 throw errtion({
307 message: `No versions were supplied to compare the engines against`,
308 code: 'editions-autoloader-invalid-versions',
309 level: 'fatal',
310 })
311 }
312
313 // Check each version
314 let compatible = false
315 for (const key in engines) {
316 if (engines.hasOwnProperty(key)) {
317 // deno's std/node/process provides both `deno` and `node` keys
318 // so we don't won't to compare node when it is actually deno
319 if (key === 'node' && versions.deno) continue
320
321 // prepare
322 const engine = engines[key]
323 const version = versions[key]
324
325 // skip for engines this edition does not care about
326 if (version == null) continue
327
328 // check compatibility against all the provided engines it does care about
329 try {
330 isCompatibleVersion(engine, version, opts)
331 compatible = true
332
333 // if any incompatibility, it is thrown, so no need to set to false
334 } catch (rangeError) {
335 throw errtion(
336 {
337 message: `The engine [${key}] range of [${engine}] was not compatible against version [${version}].`,
338 code: 'editions-autoloader-engine-error',
339 },
340 rangeError
341 )
342 }
343 }
344 }
345
346 // if there were no matching engines, then throw
347 if (!compatible) {
348 throw errtion({
349 message: `There were no supported engines in which this environment provides.`,
350 code: 'editions-autoloader-engine-mismatch',
351 })
352 }
353
354 // valid
355 return true
356}
357
358/**
359 * Checks that the {@link Edition} is compatible against the provided versions.
360 * @returns if compatible
361 * @throws if incompatible
362 */
363export function isCompatibleEdition(
364 edition: Edition,
365 opts: VersionOptions
366): true {
367 try {
368 return isCompatibleEngines(edition.engines, opts)
369 } catch (compatibleError) {
370 throw errtion(
371 {
372 message: `The edition [${edition.description}] is not compatible with this environment.`,
373 code: 'editions-autoloader-edition-incompatible',
374 },
375 compatibleError
376 )
377 }
378}
379
380/**
381 * Determine which edition should be loaded.
382 * If {@link VersionOptions.broadenRange} is unspecified (the default behavior), then we attempt to determine a suitable edition without broadening the range, and if that fails, then we try again with the range broadened.
383 * @returns any suitable editions
384 * @throws if no suitable editions
385 */
386export function determineEdition(
387 editions: Editions,
388 opts: VersionOptions
389): Edition {
390 // Prepare
391 const { broadenRange } = opts
392
393 // Check
394 if (!editions || editions.length === 0) {
395 throw errtion({
396 message: 'No editions were specified.',
397 code: 'editions-autoloader-editions-missing',
398 })
399 }
400
401 // Cycle through the editions determining the above
402 let failure: Errtion | null = null
403 for (let i = 0; i < editions.length; ++i) {
404 const edition = editions[i]
405 try {
406 isValidEdition(edition)
407 isCompatibleEdition(edition, opts)
408
409 // Success! Return the edition
410 return edition
411 } catch (error) {
412 if (error.level === 'fatal') {
413 throw errtion(
414 {
415 message: `Unable to determine a suitable edition due to failure.`,
416 code: 'editions-autoloader-fatal',
417 level: 'fatal',
418 },
419 error
420 )
421 } else if (failure) {
422 failure = errtion(error, failure)
423 } else {
424 failure = error
425 }
426 }
427 }
428
429 // Report the failure from above
430 if (failure) {
431 // try broadened
432 if (broadenRange == null)
433 try {
434 // return if broadening successfully returned an edition
435 const broadenedEdition = determineEdition(editions, {
436 ...opts,
437 broadenRange: true,
438 })
439 return {
440 ...broadenedEdition,
441 // bubble the circumstances up in case the loading of the broadened edition fails and needs to be reported
442 debugging: errtion({
443 message: `The edition ${broadenedEdition.description} was selected to be force loaded as its range was broadened.`,
444 code: 'editions-autoloader-attempt-broadened',
445 }),
446 }
447 } catch (error) {
448 throw errtion(
449 {
450 message: `Unable to determine a suitable edition, even after broadening.`,
451 code: 'editions-autoloader-none-broadened',
452 },
453 error
454 )
455 }
456
457 // fail
458 throw errtion(
459 {
460 message: `Unable to determine a suitable edition, as none were suitable.`,
461 code: 'editions-autoloader-none-suitable',
462 },
463 failure
464 )
465 }
466
467 // this should never reach here
468 throw errtion({
469 message: `Unable to determine a suitable edition, as an unexpected pathway occurred.`,
470 code: 'editions-autoloader-never',
471 })
472}
473
474/**
475 * Determine which edition should be loaded, and attempt to load it.
476 * @returns the loaded result of the suitable edition
477 * @throws if no suitable editions, or the edition failed to load
478 */
479export function solicitEdition<T>(editions: Editions, opts: SolicitOptions): T {
480 const edition = determineEdition(editions, opts)
481 try {
482 return loadEdition<T>(edition, opts)
483 } catch (error) {
484 throw errtion(error, edition.debugging)
485 }
486}
487
488/**
489 * Cycle through the editions for a package, determine the compatible edition, and load it.
490 * @returns the loaded result of the suitable edition
491 * @throws if no suitable editions, or if the edition failed to load
492 */
493export function requirePackage<T>(
494 cwd: PathOptions['cwd'],
495 loader: LoaderOptions['loader'],
496 entry: PathOptions['entry']
497): T {
498 const packagePath = resolve(cwd || '', 'package.json')
499 try {
500 // load editions
501 const { editions } = JSON.parse(readFileSync(packagePath, 'utf8'))
502
503 // load edition
504 return solicitEdition<T>(editions, {
505 versions: processVersions as any as Versions,
506 cwd,
507 loader,
508 entry,
509 })
510 } catch (error) {
511 throw errtion(
512 {
513 message: `Unable to determine a suitable edition for the package [${packagePath}] and entry [${entry}]`,
514 code: 'editions-autoloader-package',
515 },
516 error
517 )
518 }
519}