UNPKG

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