UNPKG

16.8 kBJavaScriptView Raw
1// @flow
2import type {
3 PluginOptions,
4 PackageJSON,
5 FilePath,
6 ResolveResult,
7 Environment,
8} from '@parcel/types';
9import path from 'path';
10import {isGlob} from '@parcel/utils';
11import micromatch from 'micromatch';
12import builtins from './builtins';
13import nullthrows from 'nullthrows';
14
15const EMPTY_SHIM = require.resolve('./_empty');
16
17type InternalPackageJSON = PackageJSON & {pkgdir: string, ...};
18type Options = {|
19 options: PluginOptions,
20 extensions: Array<string>,
21 mainFields: Array<string>,
22|};
23
24type Aliases =
25 | string
26 | {[string]: string, ...}
27 | {[string]: string | boolean, ...};
28type Module = {|
29 moduleName?: string,
30 subPath?: string,
31 moduleDir?: FilePath,
32 filePath?: FilePath,
33|};
34
35/**
36 * This resolver implements a modified version of the node_modules resolution algorithm:
37 * https://nodejs.org/api/modules.html#modules_all_together
38 *
39 * In addition to the standard algorithm, Parcel supports:
40 * - All file extensions supported by Parcel.
41 * - Glob file paths
42 * - Absolute paths (e.g. /foo) resolved relative to the project root.
43 * - Tilde paths (e.g. ~/foo) resolved relative to the nearest module root in node_modules.
44 * - The package.json module, jsnext:main, and browser field as replacements for package.main.
45 * - The package.json browser and alias fields as an alias map within a local module.
46 * - The package.json alias field in the root package for global aliases across all modules.
47 */
48export default class NodeResolver {
49 options: PluginOptions;
50 extensions: Array<string>;
51 mainFields: Array<string>;
52 packageCache: Map<string, InternalPackageJSON>;
53 rootPackage: InternalPackageJSON | null;
54
55 constructor(opts: Options) {
56 this.extensions = opts.extensions.map(ext =>
57 ext.startsWith('.') ? ext : '.' + ext,
58 );
59 this.mainFields = opts.mainFields;
60 this.options = opts.options;
61 this.packageCache = new Map();
62 this.rootPackage = null;
63 }
64
65 async resolve({
66 filename,
67 parent,
68 isURL,
69 env,
70 }: {|
71 filename: FilePath,
72 parent: ?FilePath,
73 isURL: boolean,
74 env: Environment,
75 |}): Promise<?ResolveResult> {
76 // Get file extensions to search
77 let extensions = this.extensions.slice();
78
79 if (parent) {
80 // parent's extension given high priority
81 let parentExt = path.extname(parent);
82 extensions = [parentExt, ...extensions.filter(ext => ext !== parentExt)];
83 }
84
85 extensions.unshift('');
86
87 // Resolve the module directory or local file path
88 let module = await this.resolveModule({filename, parent, isURL, env});
89 if (!module) {
90 return {isExcluded: true};
91 }
92
93 let resolved;
94 if (module.moduleDir) {
95 resolved = await this.loadNodeModules(module, extensions, env);
96 } else if (module.filePath) {
97 resolved = await this.loadRelative(module.filePath, extensions, env);
98 }
99
100 if (resolved) {
101 return {
102 filePath: resolved.path,
103 sideEffects:
104 resolved.pkg && !this.hasSideEffects(resolved.path, resolved.pkg)
105 ? false
106 : undefined,
107 };
108 }
109
110 return null;
111 }
112
113 async resolveModule({
114 filename,
115 parent,
116 isURL,
117 env,
118 }: {|
119 filename: string,
120 parent: ?FilePath,
121 isURL: boolean,
122 env: Environment,
123 |}): Promise<?Module> {
124 let dir = parent ? path.dirname(parent) : this.options.inputFS.cwd();
125
126 // If this isn't the entrypoint, resolve the input file to an absolute path
127 if (parent) {
128 filename = await this.resolveFilename(filename, dir, isURL);
129 }
130
131 // Resolve aliases in the parent module for this file.
132 filename = await this.loadAlias(filename, dir, env);
133
134 // Return just the file path if this is a file, not in node_modules
135 if (path.isAbsolute(filename)) {
136 return {
137 filePath: filename,
138 };
139 }
140
141 if (!this.shouldIncludeNodeModule(env, filename)) {
142 return null;
143 }
144
145 let builtin = this.findBuiltin(filename, env);
146 if (builtin || builtin === null) {
147 return builtin;
148 }
149
150 // Resolve the module in node_modules
151 let resolved;
152 try {
153 resolved = await this.findNodeModulePath(filename, dir);
154 } catch (err) {
155 // ignore
156 }
157
158 // If we couldn't resolve the node_modules path, just return the module name info
159 if (resolved === undefined) {
160 let parts = this.getModuleParts(filename);
161 resolved = {
162 moduleName: parts[0],
163 subPath: parts[1],
164 };
165 }
166
167 return resolved;
168 }
169
170 shouldIncludeNodeModule({includeNodeModules}: Environment, name: string) {
171 if (includeNodeModules === false) {
172 return false;
173 }
174
175 if (Array.isArray(includeNodeModules)) {
176 let parts = this.getModuleParts(name);
177 return includeNodeModules.includes(parts[0]);
178 }
179
180 if (includeNodeModules && typeof includeNodeModules === 'object') {
181 let parts = this.getModuleParts(name);
182 let include = includeNodeModules[parts[0]];
183 if (include != null) {
184 return !!include;
185 }
186 }
187
188 return true;
189 }
190
191 async resolveFilename(filename: string, dir: string, isURL: ?boolean) {
192 switch (filename[0]) {
193 case '/': {
194 // Absolute path. Resolve relative to project root.
195 return path.resolve(this.options.rootDir, filename.slice(1));
196 }
197
198 case '~': {
199 // Tilde path. Resolve relative to nearest node_modules directory,
200 // the nearest directory with package.json or the project root - whichever comes first.
201 const insideNodeModules = dir.includes('node_modules');
202
203 while (
204 dir !== this.options.projectRoot &&
205 path.basename(path.dirname(dir)) !== 'node_modules' &&
206 (insideNodeModules ||
207 !(await this.options.inputFS.exists(
208 path.join(dir, 'package.json'),
209 )))
210 ) {
211 dir = path.dirname(dir);
212
213 if (dir === path.dirname(dir)) {
214 dir = this.options.rootDir;
215 break;
216 }
217 }
218
219 return path.join(dir, filename.slice(1));
220 }
221
222 case '.': {
223 // Relative path.
224 return path.resolve(dir, filename);
225 }
226
227 default: {
228 if (isURL) {
229 return path.resolve(dir, filename);
230 }
231
232 // Module
233 return filename;
234 }
235 }
236 }
237
238 async loadRelative(
239 filename: string,
240 extensions: Array<string>,
241 env: Environment,
242 ) {
243 // Find a package.json file in the current package.
244 let pkg = await this.findPackage(path.dirname(filename));
245
246 // First try as a file, then as a directory.
247 return (
248 (await this.loadAsFile({file: filename, extensions, env, pkg})) ||
249 (await this.loadDirectory({dir: filename, extensions, env, pkg})) // eslint-disable-line no-return-await
250 );
251 }
252 findBuiltin(filename: string, env: Environment) {
253 if (builtins[filename]) {
254 if (env.isNode()) {
255 return null;
256 }
257
258 return {filePath: builtins[filename]};
259 }
260 }
261
262 async findNodeModulePath(filename: string, dir: string) {
263 let parts = this.getModuleParts(filename);
264 let root = path.parse(dir).root;
265
266 while (dir !== root) {
267 // Skip node_modules directories
268 if (path.basename(dir) === 'node_modules') {
269 dir = path.dirname(dir);
270 }
271
272 try {
273 // First, check if the module directory exists. This prevents a lot of unnecessary checks later.
274 let moduleDir = path.join(dir, 'node_modules', parts[0]);
275 let stats = await this.options.inputFS.stat(moduleDir);
276 if (stats.isDirectory()) {
277 return {
278 moduleName: parts[0],
279 subPath: parts[1],
280 moduleDir: moduleDir,
281 filePath: path.join(dir, 'node_modules', filename),
282 };
283 }
284 } catch (err) {
285 // ignore
286 }
287
288 // Move up a directory
289 dir = path.dirname(dir);
290 }
291
292 return undefined;
293 }
294
295 async loadNodeModules(
296 module: Module,
297 extensions: Array<string>,
298 env: Environment,
299 ) {
300 try {
301 // If a module was specified as a module sub-path (e.g. some-module/some/path),
302 // it is likely a file. Try loading it as a file first.
303 if (module.subPath && module.moduleDir) {
304 let pkg = await this.readPackage(module.moduleDir);
305 let res = await this.loadAsFile({
306 file: nullthrows(module.filePath),
307 extensions,
308 env,
309 pkg,
310 });
311 if (res) {
312 return res;
313 }
314 }
315
316 // Otherwise, load as a directory.
317 return await this.loadDirectory({
318 dir: nullthrows(module.filePath),
319 extensions,
320 env,
321 });
322 } catch (e) {
323 // ignore
324 }
325 }
326
327 async isFile(file: FilePath) {
328 try {
329 let stat = await this.options.inputFS.stat(file);
330 return stat.isFile() || stat.isFIFO();
331 } catch (err) {
332 return false;
333 }
334 }
335
336 async loadDirectory({
337 dir,
338 extensions,
339 env,
340 pkg,
341 }: {|
342 dir: string,
343 extensions: Array<string>,
344 env: Environment,
345 pkg?: InternalPackageJSON | null,
346 |}) {
347 try {
348 pkg = await this.readPackage(dir);
349
350 // Get a list of possible package entry points.
351 let entries = this.getPackageEntries(pkg, env);
352
353 for (let file of entries) {
354 // First try loading package.main as a file, then try as a directory.
355 const res =
356 (await this.loadAsFile({file, extensions, env, pkg})) ||
357 (await this.loadDirectory({dir: file, extensions, env, pkg}));
358 if (res) {
359 return res;
360 }
361 }
362 } catch (err) {
363 // ignore
364 }
365
366 // Fall back to an index file inside the directory.
367 return this.loadAsFile({
368 file: path.join(dir, 'index'),
369 extensions,
370 env,
371 pkg: pkg || null,
372 });
373 }
374
375 async readPackage(dir: string): Promise<InternalPackageJSON> {
376 let file = path.join(dir, 'package.json');
377 let cached = this.packageCache.get(file);
378
379 if (cached) {
380 return cached;
381 }
382
383 let json = await this.options.inputFS.readFile(file, 'utf8');
384 let pkg = JSON.parse(json);
385
386 pkg.pkgfile = file;
387 pkg.pkgdir = dir;
388
389 // If the package has a `source` field, check if it is behind a symlink.
390 // If so, we treat the module as source code rather than a pre-compiled module.
391 if (pkg.source) {
392 let realpath = await this.options.inputFS.realpath(file);
393 if (realpath === file) {
394 delete pkg.source;
395 }
396 }
397
398 this.packageCache.set(file, pkg);
399 return pkg;
400 }
401
402 getPackageEntries(pkg: InternalPackageJSON, env: Environment): Array<string> {
403 return this.mainFields
404 .map(field => {
405 if (field === 'browser' && pkg.browser != null) {
406 if (!env.isBrowser()) {
407 return null;
408 } else if (typeof pkg.browser === 'string') {
409 return pkg.browser;
410 } else if (typeof pkg.browser === 'object' && pkg.browser[pkg.name]) {
411 return pkg.browser[pkg.name];
412 }
413 }
414
415 return pkg[field];
416 })
417 .filter(entry => typeof entry === 'string')
418 .map(main => {
419 // Default to index file if no main field find
420 if (!main || main === '.' || main === './') {
421 main = 'index';
422 }
423
424 if (typeof main !== 'string') {
425 throw new Error('invariant: expected string');
426 }
427
428 return path.resolve(pkg.pkgdir, main);
429 });
430 }
431
432 async loadAsFile({
433 file,
434 extensions,
435 env,
436 pkg,
437 }: {|
438 file: string,
439 extensions: Array<string>,
440 env: Environment,
441 pkg: InternalPackageJSON | null,
442 |}) {
443 // Try all supported extensions
444 for (let f of await this.expandFile(file, extensions, env, pkg)) {
445 if (await this.isFile(f)) {
446 return {path: f, pkg};
447 }
448 }
449 }
450
451 async expandFile(
452 file: string,
453 extensions: Array<string>,
454 env: Environment,
455 pkg: InternalPackageJSON | null,
456 expandAliases?: boolean = true,
457 ): Promise<Array<string>> {
458 // Expand extensions and aliases
459 let res = [];
460 for (let ext of extensions) {
461 let f = file + ext;
462
463 if (expandAliases) {
464 let alias = await this.resolveAliases(file + ext, env, pkg);
465 if (alias !== f) {
466 res = res.concat(
467 await this.expandFile(alias, extensions, env, pkg, false),
468 );
469 }
470 }
471
472 res.push(f);
473 }
474
475 return res;
476 }
477
478 async resolveAliases(
479 filename: string,
480 env: Environment,
481 pkg: InternalPackageJSON | null,
482 ) {
483 // First resolve local package aliases, then project global ones.
484 return this.resolvePackageAliases(
485 await this.resolvePackageAliases(filename, env, pkg),
486 env,
487 this.rootPackage,
488 );
489 }
490
491 async resolvePackageAliases(
492 filename: string,
493 env: Environment,
494 pkg: InternalPackageJSON | null,
495 ) {
496 if (!pkg) {
497 return filename;
498 }
499
500 // Resolve aliases in the package.source, package.alias, and package.browser fields.
501 return (
502 (await this.getAlias(filename, pkg.pkgdir, pkg.source)) ||
503 (await this.getAlias(filename, pkg.pkgdir, pkg.alias)) ||
504 (env.isBrowser() &&
505 (await this.getAlias(filename, pkg.pkgdir, pkg.browser))) ||
506 filename
507 );
508 }
509
510 async getAlias(filename: FilePath, dir: FilePath, aliases: ?Aliases) {
511 if (!filename || !aliases || typeof aliases !== 'object') {
512 return null;
513 }
514
515 let alias;
516
517 // If filename is an absolute path, get one relative to the package.json directory.
518 if (path.isAbsolute(filename)) {
519 filename = path.relative(dir, filename);
520 if (filename[0] !== '.') {
521 filename = './' + filename;
522 }
523
524 alias = await this.lookupAlias(aliases, filename, dir);
525 } else {
526 // It is a node_module. First try the entire filename as a key.
527 alias = await this.lookupAlias(aliases, filename, dir);
528 if (alias == null) {
529 // If it didn't match, try only the module name.
530 let parts = this.getModuleParts(filename);
531 alias = await this.lookupAlias(aliases, parts[0], dir);
532 if (typeof alias === 'string') {
533 // Append the filename back onto the aliased module.
534 alias = path.join(alias, ...parts.slice(1));
535 }
536 }
537 }
538
539 // If the alias is set to `false`, return an empty file.
540 if (alias === false) {
541 return EMPTY_SHIM;
542 }
543
544 return alias;
545 }
546
547 lookupAlias(aliases: Aliases, filename: FilePath, dir: FilePath) {
548 if (typeof aliases !== 'object') {
549 return null;
550 }
551
552 // First, try looking up the exact filename
553 let alias = aliases[filename];
554 if (alias == null) {
555 // Otherwise, try replacing glob keys
556 for (let key in aliases) {
557 let val = aliases[key];
558 if (typeof val === 'string' && isGlob(key)) {
559 let re = micromatch.makeRe(key, {capture: true});
560 if (re.test(filename)) {
561 alias = filename.replace(re, val);
562 break;
563 }
564 }
565 }
566 }
567
568 if (typeof alias === 'string') {
569 return this.resolveFilename(alias, dir);
570 } else if (alias === false) {
571 return false;
572 }
573
574 return null;
575 }
576
577 async findPackage(dir: string) {
578 // Find the nearest package.json file within the current node_modules folder
579 let root = path.parse(dir).root;
580 while (dir !== root && path.basename(dir) !== 'node_modules') {
581 try {
582 return await this.readPackage(dir);
583 } catch (err) {
584 // ignore
585 }
586
587 dir = path.dirname(dir);
588 }
589
590 return null;
591 }
592
593 async loadAlias(filename: string, dir: string, env: Environment) {
594 // Load the root project's package.json file if we haven't already
595 if (!this.rootPackage) {
596 this.rootPackage = await this.findPackage(this.options.rootDir);
597 }
598
599 // Load the local package, and resolve aliases
600 let pkg = await this.findPackage(dir);
601 return this.resolveAliases(filename, env, pkg);
602 }
603
604 getModuleParts(name: string) {
605 let parts = path.normalize(name).split(path.sep);
606 if (parts[0].charAt(0) === '@') {
607 // Scoped module (e.g. @scope/module). Merge the first two parts back together.
608 parts.splice(0, 2, `${parts[0]}/${parts[1]}`);
609 }
610
611 return parts;
612 }
613
614 hasSideEffects(filePath: FilePath, pkg: InternalPackageJSON) {
615 switch (typeof pkg.sideEffects) {
616 case 'boolean':
617 return pkg.sideEffects;
618 case 'string':
619 return micromatch.isMatch(
620 path.relative(pkg.pkgdir, filePath),
621 pkg.sideEffects,
622 {matchBase: true},
623 );
624 case 'object':
625 return pkg.sideEffects.some(sideEffects =>
626 this.hasSideEffects(filePath, {...pkg, sideEffects}),
627 );
628 }
629
630 return true;
631 }
632}