1 | import makeDebug from 'debug';
|
2 | import Analyzer, { Import } from './analyzer';
|
3 | import Package from './package';
|
4 | import { shallowEqual } from './util';
|
5 | import { flatten, partition, values } from 'lodash';
|
6 | import {
|
7 | NodeJsInputFileSystem,
|
8 | CachedInputFileSystem,
|
9 | ResolverFactory
|
10 | } from 'enhanced-resolve';
|
11 | import pkgUp from 'pkg-up';
|
12 | import { dirname } from 'path';
|
13 | import BundleConfig from './bundle-config';
|
14 | import { AbstractInputFileSystem } from 'enhanced-resolve/lib/common-types';
|
15 |
|
16 | const debug = makeDebug('ember-auto-import:splitter');
|
17 | const resolver = ResolverFactory.createResolver({
|
18 |
|
19 | fileSystem: new CachedInputFileSystem(new NodeJsInputFileSystem(), 4000) as unknown as AbstractInputFileSystem,
|
20 | extensions: ['.js', '.json'],
|
21 | mainFields: ['browser', 'module', 'main']
|
22 | });
|
23 |
|
24 | export interface ResolvedImport {
|
25 | specifier: string;
|
26 | entrypoint: string;
|
27 | importedBy: Import[];
|
28 | }
|
29 |
|
30 | export interface BundleDependencies {
|
31 | staticImports: ResolvedImport[];
|
32 | dynamicImports: ResolvedImport[];
|
33 | }
|
34 |
|
35 | export interface SplitterOptions {
|
36 |
|
37 | bundles: BundleConfig;
|
38 | analyzers: Map<Analyzer, Package>;
|
39 | }
|
40 |
|
41 | export 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 |
|
76 |
|
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 |
|
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 |
|
140 |
|
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 |
|
202 |
|
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 |
|
227 | async function resolveEntrypoint(specifier: string, pkg: Package): Promise<string> {
|
228 | return new Promise((resolvePromise, reject) => {
|
229 |
|
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 |
|
240 | class 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 | }
|