From 597a22228c89b3f8ea6be46217e95f52353fd4a1 Mon Sep 17 00:00:00 2001 From: Hans Larsen Date: Sat, 1 Oct 2016 20:31:51 -0700 Subject: [PATCH] feat(aot): creating files in a virtual fs. In addition, reading and using AST on main.ts to figure out the entry module, if not specified. --- .../models/webpack-build-typescript.ts | 4 +- packages/webpack/src/compiler_host.ts | 195 ++++++++++++++++++ packages/webpack/src/entry_resolver.ts | 178 ++++++++++++++++ packages/webpack/src/plugin.ts | 19 +- packages/webpack/src/resource_loader.ts | 42 ++-- tests/e2e/tests/build/aot.ts | 8 + tests/e2e/tests/misc/lazy-module.ts | 14 +- 7 files changed, 430 insertions(+), 30 deletions(-) create mode 100644 packages/webpack/src/compiler_host.ts create mode 100644 packages/webpack/src/entry_resolver.ts create mode 100644 tests/e2e/tests/build/aot.ts diff --git a/packages/angular-cli/models/webpack-build-typescript.ts b/packages/angular-cli/models/webpack-build-typescript.ts index 339b03c604f6..a4f227e4ca5a 100644 --- a/packages/angular-cli/models/webpack-build-typescript.ts +++ b/packages/angular-cli/models/webpack-build-typescript.ts @@ -55,8 +55,8 @@ export const getWebpackAotConfigPartial = function(projectRoot: string, appConfi new NgcWebpackPlugin({ project: path.resolve(projectRoot, appConfig.root, appConfig.tsconfig), baseDir: path.resolve(projectRoot, ''), - entryModule: path.join(projectRoot, appConfig.root, 'app/app.module#AppModule'), - genDir: path.join(projectRoot, appConfig.outDir, 'ngfactory') + main: path.join(projectRoot, appConfig.root, appConfig.main), + genDir: path.resolve(projectRoot, '') }), ] }; diff --git a/packages/webpack/src/compiler_host.ts b/packages/webpack/src/compiler_host.ts new file mode 100644 index 000000000000..a65c46053896 --- /dev/null +++ b/packages/webpack/src/compiler_host.ts @@ -0,0 +1,195 @@ +import * as ts from 'typescript'; +import {basename, dirname} from 'path'; +import * as fs from 'fs'; + + +export interface OnErrorFn { + (message: string): void; +} + + +const dev = Math.floor(Math.random() * 10000); + + +export class VirtualStats implements fs.Stats { + protected _ctime = new Date(); + protected _mtime = new Date(); + protected _atime = new Date(); + protected _btime = new Date(); + protected _dev = dev; + protected _ino = Math.floor(Math.random() * 100000); + protected _mode = parseInt('777', 8); // RWX for everyone. + protected _uid = process.env['UID'] || 0; + protected _gid = process.env['GID'] || 0; + + constructor(protected _path: string) {} + + isFile() { return false; } + isDirectory() { return false; } + isBlockDevice() { return false; } + isCharacterDevice() { return false; } + isSymbolicLink() { return false; } + isFIFO() { return false; } + isSocket() { return false; } + + get dev() { return this._dev; } + get ino() { return this._ino; } + get mode() { return this._mode; } + get nlink() { return 1; } // Default to 1 hard link. + get uid() { return this._uid; } + get gid() { return this._gid; } + get rdev() { return 0; } + get size() { return 0; } + get blksize() { return 512; } + get blocks() { return Math.ceil(this.size / this.blksize); } + get atime() { return this._atime; } + get mtime() { return this._mtime; } + get ctime() { return this._ctime; } + get birthtime() { return this._btime; } +} + +export class VirtualDirStats extends VirtualStats { + constructor(_fileName: string) { + super(_fileName); + } + + isDirectory() { return true; } + + get size() { return 1024; } +} + +export class VirtualFileStats extends VirtualStats { + private _sourceFile: ts.SourceFile; + constructor(_fileName: string, private _content: string) { + super(_fileName); + } + + get content() { return this._content; } + set content(v: string) { + this._content = v; + this._mtime = new Date(); + } + getSourceFile(languageVersion: ts.ScriptTarget, setParentNodes: boolean) { + if (!this._sourceFile) { + this._sourceFile = ts.createSourceFile( + this._path, + this._content, + languageVersion, + setParentNodes); + } + + return this._sourceFile; + } + + isFile() { return true; } + + get size() { return this._content.length; } +} + + +export class WebpackCompilerHost implements ts.CompilerHost { + private _delegate: ts.CompilerHost; + private _files: {[path: string]: VirtualFileStats} = Object.create(null); + private _directories: {[path: string]: VirtualDirStats} = Object.create(null); + + constructor(private _options: ts.CompilerOptions, private _setParentNodes = true) { + this._delegate = ts.createCompilerHost(this._options, this._setParentNodes); + } + + private _setFileContent(fileName: string, content: string) { + this._files[fileName] = new VirtualFileStats(fileName, content); + + let p = dirname(fileName); + while (p && !this._directories[p]) { + this._directories[p] = new VirtualDirStats(p); + p = dirname(p); + } + } + + populateWebpackResolver(resolver: any) { + const fs = resolver.fileSystem; + + for (const fileName of Object.keys(this._files)) { + const stats = this._files[fileName]; + fs._statStorage.data[fileName] = [null, stats]; + fs._readFileStorage.data[fileName] = [null, stats.content]; + } + for (const path of Object.keys(this._directories)) { + const stats = this._directories[path]; + const dirs = this.getDirectories(path); + const files = this.getFiles(path); + fs._statStorage.data[path] = [null, stats]; + fs._readdirStorage.data[path] = [null, files.concat(dirs)]; + } + } + + fileExists(fileName: string): boolean { + return fileName in this._files || this._delegate.fileExists(fileName); + } + + readFile(fileName: string): string { + return (fileName in this._files) + ? this._files[fileName].content + : this._delegate.readFile(fileName); + } + + directoryExists(directoryName: string): boolean { + return (directoryName in this._directories) || this._delegate.directoryExists(directoryName); + } + + getFiles(path: string): string[] { + return Object.keys(this._files) + .filter(fileName => dirname(fileName) == path) + .map(path => basename(path)); + } + + getDirectories(path: string): string[] { + const subdirs = Object.keys(this._directories) + .filter(fileName => dirname(fileName) == path) + .map(path => basename(path)); + + let delegated: string[]; + try { + delegated = this._delegate.getDirectories(path); + } catch (e) { + delegated = []; + } + return delegated.concat(subdirs); + } + + getSourceFile(fileName: string, languageVersion: ts.ScriptTarget, onError?: OnErrorFn) { + if (!(fileName in this._files)) { + return this._delegate.getSourceFile(fileName, languageVersion, onError); + } + + return this._files[fileName].getSourceFile(languageVersion, this._setParentNodes); + } + + getCancellationToken() { + return this._delegate.getCancellationToken(); + } + + getDefaultLibFileName(options: ts.CompilerOptions) { + return this._delegate.getDefaultLibFileName(options); + } + + writeFile(fileName: string, data: string, writeByteOrderMark: boolean, onError?: OnErrorFn) { + this._setFileContent(fileName, data); + } + + getCurrentDirectory(): string { + return this._delegate.getCurrentDirectory(); + } + + getCanonicalFileName(fileName: string): string { + return this._delegate.getCanonicalFileName(fileName); + } + + useCaseSensitiveFileNames(): boolean { + return this._delegate.useCaseSensitiveFileNames(); + } + + getNewLine(): string { + return this._delegate.getNewLine(); + } +} diff --git a/packages/webpack/src/entry_resolver.ts b/packages/webpack/src/entry_resolver.ts new file mode 100644 index 000000000000..d88afc9586b5 --- /dev/null +++ b/packages/webpack/src/entry_resolver.ts @@ -0,0 +1,178 @@ +import * as fs from 'fs'; +import {dirname, join, relative, resolve} from 'path'; +import * as ts from 'typescript'; + + +function _createSource(path: string): ts.SourceFile { + return ts.createSourceFile(path, fs.readFileSync(path, 'utf-8'), ts.ScriptTarget.Latest); +} + +function _findNodes(sourceFile: ts.SourceFile, node: ts.Node, kind: ts.SyntaxKind, + keepGoing = false): ts.Node[] { + if (node.kind == kind && !keepGoing) { + return [node]; + } + + return node.getChildren(sourceFile).reduce((result, n) => { + return result.concat(_findNodes(sourceFile, n, kind, keepGoing)); + }, node.kind == kind ? [node] : []); +} + +function _recursiveSymbolExportLookup(sourcePath: string, + sourceFile: ts.SourceFile, + symbolName: string): string | null { + // Check this file. + const hasSymbol = _findNodes(sourceFile, sourceFile, ts.SyntaxKind.ClassDeclaration) + .some((cd: ts.ClassDeclaration) => { + return cd.name && cd.name.text == symbolName; + }); + if (hasSymbol) { + return sourcePath; + } + + // We found the bootstrap variable, now we just need to get where it's imported. + const exports = _findNodes(sourceFile, sourceFile, ts.SyntaxKind.ExportDeclaration, false) + .map(node => node as ts.ExportDeclaration); + + for (const decl of exports) { + if (!decl.moduleSpecifier || decl.moduleSpecifier.kind !== ts.SyntaxKind.StringLiteral) { + continue; + } + + const module = resolve(dirname(sourcePath), (decl.moduleSpecifier as ts.StringLiteral).text); + if (!decl.exportClause) { + const moduleTs = module + '.ts'; + if (fs.existsSync(moduleTs)) { + const moduleSource = _createSource(moduleTs); + const maybeModule = _recursiveSymbolExportLookup(module, moduleSource, symbolName); + if (maybeModule) { + return maybeModule; + } + } + continue; + } + + const binding = decl.exportClause as ts.NamedExports; + for (const specifier of binding.elements) { + if (specifier.name.text == symbolName) { + // If it's a directory, load its index and recursively lookup. + if (fs.statSync(module).isDirectory()) { + const indexModule = join(module, 'index.ts'); + if (fs.existsSync(indexModule)) { + const maybeModule = _recursiveSymbolExportLookup( + indexModule, _createSource(indexModule), symbolName); + if (maybeModule) { + return maybeModule; + } + } + } + + // Create the source and verify that the symbol is at least a class. + const source = _createSource(module); + const hasSymbol = _findNodes(source, source, ts.SyntaxKind.ClassDeclaration) + .some((cd: ts.ClassDeclaration) => { + return cd.name && cd.name.text == symbolName; + }); + + if (hasSymbol) { + return module; + } else { + return null; + } + } + } + } + + return null; +} + +function _symbolImportLookup(sourcePath: string, + sourceFile: ts.SourceFile, + symbolName: string): string | null { + // We found the bootstrap variable, now we just need to get where it's imported. + const imports = _findNodes(sourceFile, sourceFile, ts.SyntaxKind.ImportDeclaration, false) + .map(node => node as ts.ImportDeclaration); + + for (const decl of imports) { + if (!decl.importClause || !decl.moduleSpecifier) { + continue; + } + if (decl.moduleSpecifier.kind !== ts.SyntaxKind.StringLiteral) { + continue; + } + + const module = resolve(dirname(sourcePath), (decl.moduleSpecifier as ts.StringLiteral).text); + + if (decl.importClause.namedBindings.kind == ts.SyntaxKind.NamespaceImport) { + const binding = decl.importClause.namedBindings as ts.NamespaceImport; + if (binding.name.text == symbolName) { + // This is a default export. + return module; + } + } else if (decl.importClause.namedBindings.kind == ts.SyntaxKind.NamedImports) { + const binding = decl.importClause.namedBindings as ts.NamedImports; + for (const specifier of binding.elements) { + if (specifier.name.text == symbolName) { + // If it's a directory, load its index and recursively lookup. + if (fs.statSync(module).isDirectory()) { + const indexModule = join(module, 'index.ts'); + if (fs.existsSync(indexModule)) { + const maybeModule = _recursiveSymbolExportLookup( + indexModule, _createSource(indexModule), symbolName); + if (maybeModule) { + return maybeModule; + } + } + } + + // Create the source and verify that the symbol is at least a class. + const source = _createSource(module); + const hasSymbol = _findNodes(source, source, ts.SyntaxKind.ClassDeclaration) + .some((cd: ts.ClassDeclaration) => { + return cd.name && cd.name.text == symbolName; + }); + + if (hasSymbol) { + return module; + } else { + return null; + } + } + } + } + } + return null; +} + + +export function resolveEntryModuleFromMain(mainPath: string) { + const source = _createSource(mainPath); + + const bootstrap = _findNodes(source, source, ts.SyntaxKind.CallExpression, false) + .map(node => node as ts.CallExpression) + .filter(call => { + const access = call.expression as ts.PropertyAccessExpression; + return access.kind == ts.SyntaxKind.PropertyAccessExpression + && access.name.kind == ts.SyntaxKind.Identifier + && (access.name.text == 'bootstrapModule' + || access.name.text == 'bootstrapModuleFactory'); + }); + + if (bootstrap.length != 1 + || bootstrap[0].arguments[0].kind !== ts.SyntaxKind.Identifier) { + throw new Error('Tried to find bootstrap code, but could not. Specify either ' + + 'statically analyzable bootstrap code or pass in an entryModule ' + + 'to the plugins options.'); + } + + const bootstrapSymbolName = (bootstrap[0].arguments[0] as ts.Identifier).text; + const module = _symbolImportLookup(mainPath, source, bootstrapSymbolName); + if (module) { + return `${resolve(dirname(mainPath), module)}#${bootstrapSymbolName}`; + } + + // shrug... something bad happened and we couldn't find the import statement. + throw new Error('Tried to find bootstrap code, but could not. Specify either ' + + 'statically analyzable bootstrap code or pass in an entryModule ' + + 'to the plugins options.'); +} diff --git a/packages/webpack/src/plugin.ts b/packages/webpack/src/plugin.ts index 707c069c9ffe..92201ad1c9d8 100644 --- a/packages/webpack/src/plugin.ts +++ b/packages/webpack/src/plugin.ts @@ -9,6 +9,8 @@ import {patchReflectorHost} from './reflector_host'; import {WebpackResourceLoader} from './resource_loader'; import {createResolveDependenciesFromContextMap} from './utils'; import { AngularCompilerOptions } from '@angular/tsc-wrapped'; +import {WebpackCompilerHost} from './compiler_host'; +import {resolveEntryModuleFromMain} from './entry_resolver'; /** @@ -22,6 +24,7 @@ export interface AngularWebpackPluginOptions { baseDir: string; basePath?: string; genDir?: string; + main?: string; } @@ -32,7 +35,7 @@ export class NgcWebpackPlugin { reflector: ngCompiler.StaticReflector; reflectorHost: ngCompiler.ReflectorHost; program: ts.Program; - compilerHost: ts.CompilerHost; + compilerHost: WebpackCompilerHost; compilerOptions: ts.CompilerOptions; angularCompilerOptions: AngularCompilerOptions; files: any[]; @@ -58,13 +61,16 @@ export class NgcWebpackPlugin { this.genDir = this.options.genDir || path.resolve(process.cwd(), this.angularCompilerOptions.genDir + '/app'); this.entryModule = options.entryModule || (this.angularCompilerOptions as any).entryModule; + if (!options.entryModule && options.main) { + this.entryModule = resolveEntryModuleFromMain(options.main); + } const entryModule = this.entryModule; const [rootModule, rootNgModule] = entryModule.split('#'); this.projectPath = options.project; this.rootModule = rootModule; this.rootModuleName = rootNgModule; - this.compilerHost = ts.createCompilerHost(this.compilerOptions, true); + this.compilerHost = new WebpackCompilerHost(this.compilerOptions); this.program = ts.createProgram(this.files, this.compilerOptions, this.compilerHost); this.reflectorHost = new ngCompiler.ReflectorHost( this.program, this.compilerHost, this.angularCompilerOptions); @@ -110,6 +116,15 @@ export class NgcWebpackPlugin { compilation._ngToolsWebpackPluginInstance = null; cb(); }); + + // Virtual file system. + compiler.resolvers.normal.plugin('resolve', (request: any, cb?: () => void) => { + // populate the file system cache with the virtual module + this.compilerHost.populateWebpackResolver(compiler.resolvers.normal); + if (cb) { + cb(); + } + }); } private _make(compilation: any, cb: (err?: any, request?: any) => void) { diff --git a/packages/webpack/src/resource_loader.ts b/packages/webpack/src/resource_loader.ts index 5b1d069b9967..4b146ea1e310 100644 --- a/packages/webpack/src/resource_loader.ts +++ b/packages/webpack/src/resource_loader.ts @@ -14,27 +14,27 @@ export class WebpackResourceLoader implements ResourceLoader { private _context: string; private _uniqueId = 0; - constructor(private _compilation: any) { - this._context = _compilation.context; + constructor(private _parentCompilation: any) { + this._context = _parentCompilation.context; } private _compile(filePath: string, content: string): Promise { const compilerName = `compiler(${this._uniqueId++})`; const outputOptions = { filename: filePath }; const relativePath = path.relative(this._context || '', filePath); - const childCompiler = this._compilation.createChildCompiler(relativePath, outputOptions); + const childCompiler = this._parentCompilation.createChildCompiler(relativePath, outputOptions); childCompiler.context = this._context; childCompiler.apply( new NodeTemplatePlugin(outputOptions), new NodeTargetPlugin(), - new SingleEntryPlugin(this._context, filePath, content), + new SingleEntryPlugin(this._context, filePath), new LoaderTargetPlugin('node') ); // Store the result of the parent compilation before we start the child compilation let assetsBeforeCompilation = Object.assign( {}, - this._compilation.assets[outputOptions.filename] + this._parentCompilation.assets[outputOptions.filename] ); // Fix for "Uncaught TypeError: __webpack_require__(...) is not a function" @@ -62,17 +62,17 @@ export class WebpackResourceLoader implements ResourceLoader { reject(err); } else { // Replace [hash] placeholders in filename - const outputName = this._compilation.mainTemplate.applyPluginsWaterfall( + const outputName = this._parentCompilation.mainTemplate.applyPluginsWaterfall( 'asset-path', outputOptions.filename, { hash: childCompilation.hash, chunk: entries[0] }); // Restore the parent compilation to the state like it was before the child compilation. - this._compilation.assets[outputName] = assetsBeforeCompilation[outputName]; + this._parentCompilation.assets[outputName] = assetsBeforeCompilation[outputName]; if (assetsBeforeCompilation[outputName] === undefined) { // If it wasn't there - delete it. - delete this._compilation.assets[outputName]; + delete this._parentCompilation.assets[outputName]; } resolve({ @@ -89,24 +89,22 @@ export class WebpackResourceLoader implements ResourceLoader { } private _evaluate(fileName: string, source: string): Promise { - const vmContext = vm.createContext(Object.assign({ require: require }, global)); - const vmScript = new vm.Script(source, { filename: fileName }); - - // Evaluate code and cast to string - let newSource: string; try { - newSource = vmScript.runInContext(vmContext).toString(); + const vmContext = vm.createContext(Object.assign({require: require}, global)); + const vmScript = new vm.Script(source, {filename: fileName}); + + // Evaluate code and cast to string + let newSource: string; + newSource = vmScript.runInContext(vmContext); + + if (typeof newSource == 'string') { + return Promise.resolve(newSource); + } + + return Promise.reject('The loader "' + fileName + '" didn\'t return a string.'); } catch (e) { return Promise.reject(e); } - - if (typeof newSource == 'string') { - return Promise.resolve(newSource); - } else if (typeof newSource == 'function') { - return Promise.resolve(newSource()); - } - - return Promise.reject('The loader "' + fileName + '" didn\'t return a string.'); } get(filePath: string): Promise { diff --git a/tests/e2e/tests/build/aot.ts b/tests/e2e/tests/build/aot.ts new file mode 100644 index 000000000000..0cfe62bc7364 --- /dev/null +++ b/tests/e2e/tests/build/aot.ts @@ -0,0 +1,8 @@ +import {ng} from '../../utils/process'; +import {expectFileToMatch} from '../../utils/fs'; + +export default function() { + return ng('build', '--aot') + .then(() => expectFileToMatch('dist/main.bundle.js', + /bootstrapModuleFactory.*\/\* AppModuleNgFactory \*\//)); +} diff --git a/tests/e2e/tests/misc/lazy-module.ts b/tests/e2e/tests/misc/lazy-module.ts index 9f25a42e663f..ad7b4cfab6ac 100644 --- a/tests/e2e/tests/misc/lazy-module.ts +++ b/tests/e2e/tests/misc/lazy-module.ts @@ -12,8 +12,6 @@ export default function(argv: any) { } let oldNumberOfFiles = 0; - let currentNumberOfDistFiles = 0; - return Promise.resolve() .then(() => ng('build')) .then(() => oldNumberOfFiles = readdirSync('dist').length) @@ -22,8 +20,16 @@ export default function(argv: any) { RouterModule.forRoot([{ path: "lazy", loadChildren: "app/lazy/lazy.module#LazyModule" }]) `, '@angular/router')) .then(() => ng('build')) - .then(() => currentNumberOfDistFiles = readdirSync('dist').length) - .then(() => { + .then(() => readdirSync('dist').length) + .then(currentNumberOfDistFiles => { + if (oldNumberOfFiles >= currentNumberOfDistFiles) { + throw new Error('A bundle for the lazy module was not created.'); + } + }) + // Check for AoT and lazy routes. + .then(() => ng('build', '--aot')) + .then(() => readdirSync('dist').length) + .then(currentNumberOfDistFiles => { if (oldNumberOfFiles >= currentNumberOfDistFiles) { throw new Error('A bundle for the lazy module was not created.'); }