UNPKG

8.26 kBPlain TextView Raw
1import makeDebug from 'debug';
2import Analyzer, { Import } from './analyzer';
3import Package from './package';
4import { shallowEqual } from './util';
5import { flatten, partition, values } from 'lodash';
6import {
7 NodeJsInputFileSystem,
8 CachedInputFileSystem,
9 ResolverFactory
10} from 'enhanced-resolve';
11import pkgUp from 'pkg-up';
12import { dirname } from 'path';
13import BundleConfig from './bundle-config';
14import { AbstractInputFileSystem } from 'enhanced-resolve/lib/common-types';
15
16const debug = makeDebug('ember-auto-import:splitter');
17const resolver = ResolverFactory.createResolver({
18 // upstream types seem to be broken here
19 fileSystem: new CachedInputFileSystem(new NodeJsInputFileSystem(), 4000) as unknown as AbstractInputFileSystem,
20 extensions: ['.js', '.json'],
21 mainFields: ['browser', 'module', 'main']
22});
23
24export interface ResolvedImport {
25 specifier: string;
26 entrypoint: string;
27 importedBy: Import[];
28}
29
30export interface BundleDependencies {
31 staticImports: ResolvedImport[];
32 dynamicImports: ResolvedImport[];
33}
34
35export interface SplitterOptions {
36 // list of bundle names in priority order
37 bundles: BundleConfig;
38 analyzers: Map<Analyzer, Package>;
39}
40
41export default class Splitter {
42 private lastImports: Import[][] | undefined;
43 private lastDeps: Map<string, BundleDependencies> | null = null;
44 private packageVersions: Map<string, string> = new Map();
45
46 constructor(private options: SplitterOptions) {}
47
48 async deps(): Promise<Map<string, BundleDependencies>> {
49 if (this.importsChanged()) {
50 this.lastDeps = await this.computeDeps(this.options.analyzers);
51 debug('output %s', new LazyPrintDeps(this.lastDeps));
52 }
53 return this.lastDeps!;
54 }
55
56 private importsChanged(): boolean {
57 let imports = [...this.options.analyzers.keys()].map(
58 analyzer => analyzer.imports
59 );
60 if (!this.lastImports || !shallowEqual(this.lastImports, imports)) {
61 this.lastImports = imports;
62 return true;
63 }
64 return false;
65 }
66
67 private async computeTargets(analyzers: Map<Analyzer, Package>) {
68 let specifiers: Map<string, ResolvedImport> = new Map();
69 let imports = flatten(
70 [...analyzers.keys()].map(analyzer => analyzer.imports)
71 );
72 await Promise.all(
73 imports.map(async imp => {
74 if (imp.specifier[0] === '.' || imp.specifier[0] === '/') {
75 // we're only trying to identify imports of external NPM
76 // packages, so relative imports are never relevant.
77 return;
78 }
79
80 let aliasedSpecifier = imp.package.aliasFor(imp.specifier);
81 let parts = aliasedSpecifier.split('/');
82 let packageName;
83 if (aliasedSpecifier[0] === '@') {
84 packageName = `${parts[0]}/${parts[1]}`;
85 } else {
86 packageName = parts[0];
87 }
88
89 if (imp.package.excludesDependency(packageName)) {
90 // This package has been explicitly excluded.
91 return;
92 }
93
94 if (
95 !imp.package.hasDependency(packageName) ||
96 imp.package.isEmberAddonDependency(packageName)
97 ) {
98 return;
99 }
100 imp.package.assertAllowedDependency(packageName);
101
102 let entrypoint = await resolveEntrypoint(aliasedSpecifier, imp.package);
103 let seenAlready = specifiers.get(imp.specifier);
104 if (seenAlready) {
105 await this.assertSafeVersion(seenAlready, imp, entrypoint);
106 seenAlready.importedBy.push(imp);
107 } else {
108 specifiers.set(imp.specifier, {
109 specifier: imp.specifier,
110 entrypoint,
111 importedBy: [imp]
112 });
113 }
114 })
115 );
116 return specifiers;
117 }
118
119 private async versionOfPackage(entrypoint: string) {
120 if (this.packageVersions.has(entrypoint)) {
121 return this.packageVersions.get(entrypoint);
122 }
123 let pkgPath = await pkgUp(dirname(entrypoint));
124 let version = null;
125 if (pkgPath) {
126 let pkg = require(pkgPath);
127 version = pkg.version;
128 }
129 this.packageVersions.set(entrypoint, version);
130 return version;
131 }
132
133 private async assertSafeVersion(
134 have: ResolvedImport,
135 nextImport: Import,
136 entrypoint: string
137 ) {
138 if (have.entrypoint === entrypoint) {
139 // both import statements are resolving to the exact same entrypoint --
140 // this is the normal and happy case
141 return;
142 }
143
144 let [haveVersion, nextVersion] = await Promise.all([
145 this.versionOfPackage(have.entrypoint),
146 this.versionOfPackage(entrypoint)
147 ]);
148 if (haveVersion !== nextVersion) {
149 throw new Error(
150 `${nextImport.package.name} and ${
151 have.importedBy[0].package.name
152 } are using different versions of ${
153 have.specifier
154 } (${nextVersion} located at ${entrypoint} vs ${haveVersion} located at ${
155 have.entrypoint
156 })`
157 );
158 }
159 }
160
161 private async computeDeps(analyzers: SplitterOptions["analyzers"]): Promise<Map<string, BundleDependencies>> {
162 let targets = await this.computeTargets(analyzers);
163 let deps: Map<string, BundleDependencies> = new Map();
164
165 this.options.bundles.names.forEach(bundleName => {
166 deps.set(bundleName, { staticImports: [], dynamicImports: [] });
167 });
168
169 for (let target of targets.values()) {
170 let [dynamicUses, staticUses] = partition(
171 target.importedBy,
172 imp => imp.isDynamic
173 );
174 if (staticUses.length > 0) {
175 let bundleName = this.chooseBundle(staticUses);
176 deps.get(bundleName)!.staticImports.push(target);
177 }
178 if (dynamicUses.length > 0) {
179 let bundleName = this.chooseBundle(dynamicUses);
180 deps.get(bundleName)!.dynamicImports.push(target);
181 }
182 }
183
184 this.sortDependencies(deps);
185
186 return deps;
187 }
188
189 private sortDependencies(deps: Map<string, BundleDependencies>) {
190 for (const bundle of deps.values()) {
191 this.sortBundle(bundle);
192 }
193 }
194
195 private sortBundle(bundle: BundleDependencies) {
196 for (const imports of values(bundle)) {
197 imports.sort((a, b) => a.specifier.localeCompare(b.specifier));
198 }
199 }
200
201 // given that a module is imported by the given list of paths, which
202 // bundle should it go in?
203 private chooseBundle(importedBy: Import[]) {
204 let usedInBundles = {} as { [bundleName: string]: boolean };
205 importedBy.forEach(usage => {
206 usedInBundles[this.bundleForPath(usage)] = true;
207 });
208 return this.options.bundles.names.find(bundle => usedInBundles[bundle])!;
209 }
210
211 private bundleForPath(usage: Import) {
212 let bundleName = this.options.bundles.bundleForPath(usage.path);
213 if (this.options.bundles.names.indexOf(bundleName) === -1) {
214 throw new Error(
215 `bundleForPath("${
216 usage.path
217 }") returned ${bundleName}" but the only configured bundle names are ${this.options.bundles.names.join(
218 ','
219 )}`
220 );
221 }
222 debug('bundleForPath("%s")=%s', usage.path, bundleName);
223 return bundleName;
224 }
225}
226
227async function resolveEntrypoint(specifier: string, pkg: Package): Promise<string> {
228 return new Promise((resolvePromise, reject) => {
229 // upstream types seem to be out of date here
230 (resolver.resolve as any)({}, pkg.root, specifier, {}, (err: Error, path: string) => {
231 if (err) {
232 reject(err);
233 } else {
234 resolvePromise(path);
235 }
236 });
237 }) as Promise<string>;
238}
239
240class LazyPrintDeps {
241 constructor(private deps: Map<string, BundleDependencies>) {}
242
243 private describeResolvedImport(imp: ResolvedImport) {
244 return {
245 specifier: imp.specifier,
246 entrypoint: imp.entrypoint,
247 importedBy: imp.importedBy.map(this.describeImport.bind(this))
248 };
249 }
250
251 private describeImport(imp: Import) {
252 return {
253 package: imp.package.name,
254 path: imp.path,
255 isDynamic: imp.isDynamic
256 };
257 }
258
259 toString() {
260 let output = {} as { [bundle: string]: any };
261 for (let [
262 bundle,
263 { staticImports, dynamicImports }
264 ] of this.deps.entries()) {
265 output[bundle] = {
266 static: staticImports.map(this.describeResolvedImport.bind(this)),
267 dynamic: dynamicImports.map(this.describeResolvedImport.bind(this))
268 };
269 }
270 return JSON.stringify(output, null, 2);
271 }
272}