diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts index c0f719e20bb3..a81e728e45de 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts @@ -10,7 +10,12 @@ // tslint:disable-next-line:no-implicit-dependencies import * as ts from 'typescript'; -import { AssetPatternObject, Budget, ExtraEntryPoint } from '../../browser/schema'; +import { + AssetPatternObject, + Budget, + CurrentFileReplacement, + ExtraEntryPoint, +} from '../../browser/schema'; export interface BuildOptions { optimization: boolean; @@ -58,6 +63,7 @@ export interface BuildOptions { stylePreprocessorOptions?: { includePaths: string[] }; lazyModules: string[]; platform?: 'browser' | 'server'; + fileReplacements: CurrentFileReplacement[]; } export interface WebpackTestOptions extends BuildOptions { diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/typescript.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/typescript.ts index 6db7273b6cc4..9583208abab8 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/typescript.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/typescript.ts @@ -7,7 +7,7 @@ */ // tslint:disable // TODO: cleanup this file, it's copied as is from Angular CLI. -import { tags, virtualFs } from '@angular-devkit/core'; +import { virtualFs } from '@angular-devkit/core'; import { Stats } from 'fs'; import * as path from 'path'; import { @@ -28,9 +28,9 @@ const webpackLoader: string = g['_DevKitIsLocal'] function _createAotPlugin( wco: WebpackConfigOptions, options: any, - host: virtualFs.Host, + _host: virtualFs.Host, useMain = true, - extract = false + extract = false, ) { const { root, buildOptions } = wco; options.compilerOptions = options.compilerOptions || {}; @@ -62,6 +62,13 @@ function _createAotPlugin( } } + const hostReplacementPaths: { [replace: string]: string } = {}; + if (buildOptions.fileReplacements) { + for (const replacement of buildOptions.fileReplacements) { + hostReplacementPaths[replacement.replace] = replacement.with; + } + } + const pluginOptions: AngularCompilerPluginOptions = { mainPath: useMain ? path.join(root, buildOptions.main) : undefined, ...i18nFileAndFormat, @@ -70,11 +77,11 @@ function _createAotPlugin( missingTranslation: buildOptions.i18nMissingTranslation, sourceMap: buildOptions.sourceMap, additionalLazyModules, + hostReplacementPaths, nameLazyFiles: buildOptions.namedChunks, forkTypeChecker: buildOptions.forkTypeChecker, contextElementDependencyConstructor: require('webpack/lib/dependencies/ContextElementDependency'), ...options, - host, }; return new AngularCompilerPlugin(pluginOptions); } diff --git a/packages/angular_devkit/build_angular/src/browser/index.ts b/packages/angular_devkit/build_angular/src/browser/index.ts index 9dff620ff51a..a4c12abc36a9 100644 --- a/packages/angular_devkit/build_angular/src/browser/index.ts +++ b/packages/angular_devkit/build_angular/src/browser/index.ts @@ -34,7 +34,7 @@ import { statsToString, statsWarningsToString, } from '../angular-cli-files/utilities/stats'; -import { addFileReplacements, normalizeAssetPatterns } from '../utils'; +import { normalizeAssetPatterns, normalizeFileReplacements } from '../utils'; import { AssetPatternObject, BrowserBuilderSchema, CurrentFileReplacement } from './schema'; const webpackMerge = require('webpack-merge'); @@ -64,7 +64,8 @@ export class BrowserBuilder implements Builder { concatMap(() => options.deleteOutputPath ? this._deleteOutputDir(root, normalize(options.outputPath), this.context.host) : of(null)), - concatMap(() => addFileReplacements(root, host, options.fileReplacements)), + concatMap(() => normalizeFileReplacements(options.fileReplacements, host, root)), + tap(fileReplacements => options.fileReplacements = fileReplacements), concatMap(() => normalizeAssetPatterns( options.assets, host, root, projectRoot, builderConfig.sourceRoot)), // Replace the assets in options with the normalized version. diff --git a/packages/angular_devkit/build_angular/src/dev-server/index.ts b/packages/angular_devkit/build_angular/src/dev-server/index.ts index d368aab2bc3c..70c3d8f58533 100644 --- a/packages/angular_devkit/build_angular/src/dev-server/index.ts +++ b/packages/angular_devkit/build_angular/src/dev-server/index.ts @@ -25,7 +25,7 @@ import * as WebpackDevServer from 'webpack-dev-server'; import { checkPort } from '../angular-cli-files/utilities/check-port'; import { BrowserBuilder, NormalizedBrowserBuilderSchema, getBrowserLoggingCb } from '../browser/'; import { BrowserBuilderSchema } from '../browser/schema'; -import { addFileReplacements, normalizeAssetPatterns } from '../utils'; +import { normalizeAssetPatterns, normalizeFileReplacements } from '../utils'; const opn = require('opn'); @@ -78,7 +78,8 @@ export class DevServerBuilder implements Builder { tap((port) => options.port = port), concatMap(() => this._getBrowserOptions(options)), tap((opts) => browserOptions = opts), - concatMap(() => addFileReplacements(root, host, browserOptions.fileReplacements)), + concatMap(() => normalizeFileReplacements(browserOptions.fileReplacements, host, root)), + tap(fileReplacements => browserOptions.fileReplacements = fileReplacements), concatMap(() => normalizeAssetPatterns( browserOptions.assets, host, root, projectRoot, builderConfig.sourceRoot)), // Replace the assets in options with the normalized version. diff --git a/packages/angular_devkit/build_angular/src/karma/index.ts b/packages/angular_devkit/build_angular/src/karma/index.ts index e880dfde8c39..09474bf35f70 100644 --- a/packages/angular_devkit/build_angular/src/karma/index.ts +++ b/packages/angular_devkit/build_angular/src/karma/index.ts @@ -27,7 +27,7 @@ import { import { readTsconfig } from '../angular-cli-files/utilities/read-tsconfig'; import { requireProjectModule } from '../angular-cli-files/utilities/require-project-module'; import { AssetPatternObject, CurrentFileReplacement } from '../browser/schema'; -import { addFileReplacements, normalizeAssetPatterns } from '../utils'; +import { normalizeAssetPatterns, normalizeFileReplacements } from '../utils'; import { KarmaBuilderSchema } from './schema'; const webpackMerge = require('webpack-merge'); @@ -47,7 +47,8 @@ export class KarmaBuilder implements Builder { const host = new virtualFs.AliasHost(this.context.host as virtualFs.Host); return of(null).pipe( - concatMap(() => addFileReplacements(root, host, options.fileReplacements)), + concatMap(() => normalizeFileReplacements(options.fileReplacements, host, root)), + tap(fileReplacements => options.fileReplacements = fileReplacements), concatMap(() => normalizeAssetPatterns( options.assets, host, root, projectRoot, builderConfig.sourceRoot)), // Replace the assets in options with the normalized version. diff --git a/packages/angular_devkit/build_angular/src/server/index.ts b/packages/angular_devkit/build_angular/src/server/index.ts index b70f6715f8be..140ad0e6ae79 100644 --- a/packages/angular_devkit/build_angular/src/server/index.ts +++ b/packages/angular_devkit/build_angular/src/server/index.ts @@ -16,7 +16,7 @@ import { WebpackBuilder } from '@angular-devkit/build-webpack'; import { Path, getSystemPath, normalize, resolve, virtualFs } from '@angular-devkit/core'; import { Stats } from 'fs'; import { Observable, concat, of } from 'rxjs'; -import { concatMap, last } from 'rxjs/operators'; +import { concatMap, last, tap } from 'rxjs/operators'; import * as ts from 'typescript'; // tslint:disable-line:no-implicit-dependencies import { WebpackConfigOptions } from '../angular-cli-files/models/build-options'; import { @@ -30,7 +30,7 @@ import { import { readTsconfig } from '../angular-cli-files/utilities/read-tsconfig'; import { requireProjectModule } from '../angular-cli-files/utilities/require-project-module'; import { getBrowserLoggingCb } from '../browser'; -import { addFileReplacements } from '../utils'; +import { normalizeFileReplacements } from '../utils'; import { BuildWebpackServerSchema } from './schema'; const webpackMerge = require('webpack-merge'); @@ -51,7 +51,8 @@ export class ServerBuilder implements Builder { concatMap(() => options.deleteOutputPath ? this._deleteOutputDir(root, normalize(options.outputPath), this.context.host) : of(null)), - concatMap(() => addFileReplacements(root, host, options.fileReplacements)), + concatMap(() => normalizeFileReplacements(options.fileReplacements, host, root)), + tap(fileReplacements => options.fileReplacements = fileReplacements), concatMap(() => { const webpackConfig = this.buildWebpackConfig(root, projectRoot, host, options); diff --git a/packages/angular_devkit/build_angular/src/utils/add-file-replacements.ts b/packages/angular_devkit/build_angular/src/utils/add-file-replacements.ts deleted file mode 100644 index f776160f0328..000000000000 --- a/packages/angular_devkit/build_angular/src/utils/add-file-replacements.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { BaseException, Path, join, normalize, virtualFs } from '@angular-devkit/core'; -import { Observable, forkJoin, of } from 'rxjs'; -import { map, tap } from 'rxjs/operators'; -import { - CurrentFileReplacement, - DeprecatedFileReplacment, - FileReplacement, -} from '../browser/schema'; - - -export class MissingFileReplacementException extends BaseException { - constructor(path: String) { - super(`The ${path} path in file replacements does not exist.`); - } -} - -// Note: This method changes the file replacements in place. -export function addFileReplacements( - root: Path, - host: virtualFs.AliasHost, - fileReplacements: FileReplacement[], -): Observable { - - if (fileReplacements.length === 0) { - return of(null); - } - - // Normalize the legacy format into the current one. - for (const fileReplacement of fileReplacements) { - const currentFormat = fileReplacement as CurrentFileReplacement; - const maybeOldFormat = fileReplacement as DeprecatedFileReplacment; - - if (maybeOldFormat.src && maybeOldFormat.replaceWith) { - currentFormat.replace = maybeOldFormat.src; - currentFormat.with = maybeOldFormat.replaceWith; - } - } - - const normalizedFileReplacements = fileReplacements as CurrentFileReplacement[]; - - // Ensure all the replacements exist. - const errorOnFalse = (path: string) => tap((exists: boolean) => { - if (!exists) { - throw new MissingFileReplacementException(path); - } - }); - - const existObservables = normalizedFileReplacements - .map(replacement => [ - host.exists(join(root, replacement.replace)).pipe(errorOnFalse(replacement.replace)), - host.exists(join(root, replacement.with)).pipe(errorOnFalse(replacement.with)), - ]) - .reduce((prev, curr) => prev.concat(curr), []); - - return forkJoin(existObservables).pipe( - tap(() => { - normalizedFileReplacements.forEach(replacement => { - host.aliases.set( - join(root, normalize(replacement.replace)), - join(root, normalize(replacement.with)), - ); - }); - }), - map(() => null), - ); -} diff --git a/packages/angular_devkit/build_angular/src/utils/index.ts b/packages/angular_devkit/build_angular/src/utils/index.ts index edfd8da8167f..488c3f4c9e8d 100644 --- a/packages/angular_devkit/build_angular/src/utils/index.ts +++ b/packages/angular_devkit/build_angular/src/utils/index.ts @@ -7,5 +7,5 @@ */ export * from './run-module-as-observable-fork'; -export * from './add-file-replacements'; +export * from './normalize-file-replacements'; export * from './normalize-asset-patterns'; diff --git a/packages/angular_devkit/build_angular/src/utils/normalize-file-replacements.ts b/packages/angular_devkit/build_angular/src/utils/normalize-file-replacements.ts new file mode 100644 index 000000000000..9345e2aa85d9 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/normalize-file-replacements.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + BaseException, + Path, + getSystemPath, + join, + normalize, + virtualFs, +} from '@angular-devkit/core'; +import { Observable, from, of } from 'rxjs'; +import { concat, concatMap, ignoreElements, map, mergeMap, tap, toArray } from 'rxjs/operators'; +import { + CurrentFileReplacement, + DeprecatedFileReplacment, + FileReplacement, +} from '../browser/schema'; + + +export class MissingFileReplacementException extends BaseException { + constructor(path: String) { + super(`The ${path} path in file replacements does not exist.`); + } +} + +export interface NormalizedFileReplacement { + replace: Path; + with: Path; +} + +export function normalizeFileReplacements( + fileReplacements: FileReplacement[], + host: virtualFs.Host, + root: Path, +): Observable { + if (fileReplacements.length === 0) { + return of([]); + } + + // Ensure all the replacements exist. + const errorOnFalse = (path: Path) => tap((exists: boolean) => { + if (!exists) { + throw new MissingFileReplacementException(getSystemPath(path)); + } + }); + + return from(fileReplacements).pipe( + map(replacement => normalizeFileReplacement(replacement, root)), + concatMap(normalized => { + return from([normalized.replace, normalized.with]).pipe( + mergeMap(path => host.exists(path).pipe(errorOnFalse(path))), + ignoreElements(), + concat(of(normalized)), + ); + }), + toArray(), + ); +} + +function normalizeFileReplacement( + fileReplacement: FileReplacement, + root?: Path, +): NormalizedFileReplacement { + const currentFormat = fileReplacement as CurrentFileReplacement; + const maybeOldFormat = fileReplacement as DeprecatedFileReplacment; + + let replacePath: Path; + let withPath: Path; + if (maybeOldFormat.src && maybeOldFormat.replaceWith) { + replacePath = normalize(maybeOldFormat.src); + withPath = normalize(maybeOldFormat.replaceWith); + } else { + replacePath = normalize(currentFormat.replace); + withPath = normalize(currentFormat.with); + } + + // TODO: For 7.x should this only happen if not absolute? + if (root) { + replacePath = join(root, replacePath); + } + if (root) { + withPath = join(root, withPath); + } + + return { replace: replacePath, with: withPath }; +} diff --git a/packages/angular_devkit/build_angular/src/utils/webpack-file-system-host-adapter.ts b/packages/angular_devkit/build_angular/src/utils/webpack-file-system-host-adapter.ts index 461d1ce88d77..0b95101bb98f 100644 --- a/packages/angular_devkit/build_angular/src/utils/webpack-file-system-host-adapter.ts +++ b/packages/angular_devkit/build_angular/src/utils/webpack-file-system-host-adapter.ts @@ -93,9 +93,9 @@ export class WebpackFileSystemHostAdapter implements InputFileSystem { return this._doHostCall(this._host.list(normalize('/' + path)), callback); } - readFile(path: string, callback: Callback): void { + readFile(path: string, callback: Callback): void { const o = this._host.read(normalize('/' + path)).pipe( - map(content => virtualFs.fileBufferToString(content)), + map(content => Buffer.from(content)), ); return this._doHostCall(o, callback); @@ -134,15 +134,21 @@ export class WebpackFileSystemHostAdapter implements InputFileSystem { return this._syncHost.list(normalize('/' + path)); } - readFileSync(path: string): string { + readFileSync(path: string): Buffer { if (!this._syncHost) { this._syncHost = new virtualFs.SyncDelegateHost(this._host); } - return virtualFs.fileBufferToString(this._syncHost.read(normalize('/' + path))); + return Buffer.from(this._syncHost.read(normalize('/' + path))); } - readJsonSync(path: string): string { - return JSON.parse(this.readFileSync(path)); + readJsonSync(path: string): {} { + if (!this._syncHost) { + this._syncHost = new virtualFs.SyncDelegateHost(this._host); + } + + const data = this._syncHost.read(normalize('/' + path)); + + return JSON.parse(virtualFs.fileBufferToString(data)); } readlinkSync(path: string): string { const err: NodeJS.ErrnoException = new Error('Not a symlink.'); diff --git a/packages/angular_devkit/build_angular/test/browser/replacements_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/replacements_spec_large.ts index f96ac4a10a0a..f03555356f3f 100644 --- a/packages/angular_devkit/build_angular/test/browser/replacements_spec_large.ts +++ b/packages/angular_devkit/build_angular/test/browser/replacements_spec_large.ts @@ -8,7 +8,7 @@ import { runTargetSpec } from '@angular-devkit/architect/testing'; import { join, normalize, virtualFs } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; +import { debounceTime, take, tap } from 'rxjs/operators'; import { browserTargetSpec, host } from '../utils'; @@ -100,4 +100,45 @@ describe('Browser Builder file replacements', () => { runTargetSpec(host, browserTargetSpec, overrides) .subscribe(undefined, () => done(), done.fail); }); + + it('file replacements work with watch mode', (done) => { + const overrides = { + fileReplacements: [ + { + replace: normalize('/src/meaning.ts'), + with: normalize('/src/meaning-too.ts'), + }, + ], + watch: true, + }; + + let buildNumber = 0; + + runTargetSpec(host, browserTargetSpec, overrides, 45000).pipe( + debounceTime(1000), + tap((buildEvent) => expect(buildEvent.success).toBe(true)), + tap(() => { + const fileName = join(outputPath, 'main.js'); + const content = virtualFs.fileBufferToString(host.scopedSync().read(fileName)); + buildNumber += 1; + + switch (buildNumber) { + case 1: + expect(content).toMatch(/meaning\s*=\s*42/); + expect(content).not.toMatch(/meaning\s*=\s*10/); + host.writeMultipleFiles({ + 'src/meaning-too.ts': 'export var meaning = 84;', + }); + break; + + case 2: + expect(content).toMatch(/meaning\s*=\s*84/); + expect(content).not.toMatch(/meaning\s*=\s*42/); + break; + } + }), + take(2), + ).toPromise().then(() => done(), done.fail); + }); + }); diff --git a/packages/angular_devkit/core/src/virtual-fs/host/index.ts b/packages/angular_devkit/core/src/virtual-fs/host/index.ts index c044890aeb76..f7c963dcfc86 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/index.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/index.ts @@ -16,6 +16,7 @@ export * from './record'; export * from './safe'; export * from './scoped'; export * from './sync'; +export * from './resolver'; import * as test from './test'; diff --git a/packages/ngtools/webpack/package.json b/packages/ngtools/webpack/package.json index 4994d170a917..f80fc090b473 100644 --- a/packages/ngtools/webpack/package.json +++ b/packages/ngtools/webpack/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@angular-devkit/core": "0.0.0", + "rxjs": "^6.0.0", "tree-kill": "^1.0.0", "webpack-sources": "^1.1.0" }, diff --git a/packages/ngtools/webpack/src/angular_compiler_plugin.ts b/packages/ngtools/webpack/src/angular_compiler_plugin.ts index f6f2c8fe5a07..bc6151078dfa 100644 --- a/packages/ngtools/webpack/src/angular_compiler_plugin.ts +++ b/packages/ngtools/webpack/src/angular_compiler_plugin.ts @@ -5,7 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import { dirname, normalize, resolve, virtualFs } from '@angular-devkit/core'; +import { Path, dirname, getSystemPath, normalize, resolve, virtualFs } from '@angular-devkit/core'; import { ChildProcess, ForkOptions, fork } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; @@ -57,6 +57,7 @@ import { NodeWatchFileSystemInterface, NormalModuleFactoryRequest, } from './webpack'; +import { WebpackInputHost } from './webpack-input-host'; const treeKill = require('tree-kill'); @@ -76,7 +77,7 @@ export interface AngularCompilerPluginOptions { entryModule?: string; mainPath?: string; skipCodeGeneration?: boolean; - hostReplacementPaths?: { [path: string]: string }; + hostReplacementPaths?: { [path: string]: string } | ((path: string) => string); forkTypeChecker?: boolean; // TODO: remove singleFileIncludes for 2.0, this is just to support old projects that did not // include 'polyfills.ts' in `tsconfig.spec.json'. @@ -288,40 +289,6 @@ export class AngularCompilerPlugin { this._contextElementDependencyConstructor = options.contextElementDependencyConstructor || require('webpack/lib/dependencies/ContextElementDependency'); - // Create the webpack compiler host. - const webpackCompilerHost = new WebpackCompilerHost( - this._compilerOptions, - this._basePath, - this._options.host, - ); - webpackCompilerHost.enableCaching(); - - // Create and set a new WebpackResourceLoader. - this._resourceLoader = new WebpackResourceLoader(); - webpackCompilerHost.setResourceLoader(this._resourceLoader); - - // Use the WebpackCompilerHost with a resource loader to create an AngularCompilerHost. - this._compilerHost = createCompilerHost({ - options: this._compilerOptions, - tsHost: webpackCompilerHost, - }) as CompilerHost & WebpackCompilerHost; - - // Override some files in the FileSystem with paths from the actual file system. - if (this._options.hostReplacementPaths) { - for (const filePath of Object.keys(this._options.hostReplacementPaths)) { - const replacementFilePath = this._options.hostReplacementPaths[filePath]; - const content = this._compilerHost.readFile(replacementFilePath); - if (content) { - this._compilerHost.writeFile(filePath, content, false); - } - } - } - - // Resolve mainPath if provided. - if (options.mainPath) { - this._mainPath = this._compilerHost.resolve(options.mainPath); - } - // Use entryModule if available in options, otherwise resolve it from mainPath after program // creation. if (this._options.entryModule) { @@ -612,6 +579,56 @@ export class AngularCompilerPlugin { watchFileSystem: NodeWatchFileSystemInterface, }; + let host: virtualFs.Host = this._options.host || new WebpackInputHost( + compilerWithFileSystems.inputFileSystem, + ); + + let replacements: Map | ((path: Path) => Path) | undefined; + if (this._options.hostReplacementPaths) { + if (typeof this._options.hostReplacementPaths == 'function') { + const replacementResolver = this._options.hostReplacementPaths; + replacements = path => normalize(replacementResolver(getSystemPath(path))); + host = new class extends virtualFs.ResolverHost { + _resolve(path: Path) { + return normalize(replacementResolver(getSystemPath(path))); + } + }(host); + } else { + replacements = new Map(); + const aliasHost = new virtualFs.AliasHost(host); + for (const from in this._options.hostReplacementPaths) { + const normalizedFrom = normalize(from); + const normalizedWith = normalize(this._options.hostReplacementPaths[from]); + aliasHost.aliases.set(normalizedFrom, normalizedWith); + replacements.set(normalizedFrom, normalizedWith); + } + host = aliasHost; + } + } + + // Create the webpack compiler host. + const webpackCompilerHost = new WebpackCompilerHost( + this._compilerOptions, + this._basePath, + host, + ); + webpackCompilerHost.enableCaching(); + + // Create and set a new WebpackResourceLoader. + this._resourceLoader = new WebpackResourceLoader(); + webpackCompilerHost.setResourceLoader(this._resourceLoader); + + // Use the WebpackCompilerHost with a resource loader to create an AngularCompilerHost. + this._compilerHost = createCompilerHost({ + options: this._compilerOptions, + tsHost: webpackCompilerHost, + }) as CompilerHost & WebpackCompilerHost; + + // Resolve mainPath if provided. + if (this._options.mainPath) { + this._mainPath = this._compilerHost.resolve(this._options.mainPath); + } + const inputDecorator = new VirtualFileSystemDecorator( compilerWithFileSystems.inputFileSystem, this._compilerHost, @@ -619,6 +636,7 @@ export class AngularCompilerPlugin { compilerWithFileSystems.inputFileSystem = inputDecorator; compilerWithFileSystems.watchFileSystem = new VirtualWatchFileSystemDecorator( inputDecorator, + replacements, ); }); diff --git a/packages/ngtools/webpack/src/virtual_file_system_decorator.ts b/packages/ngtools/webpack/src/virtual_file_system_decorator.ts index 205a87e2c391..aedf56665b6f 100644 --- a/packages/ngtools/webpack/src/virtual_file_system_decorator.ts +++ b/packages/ngtools/webpack/src/virtual_file_system_decorator.ts @@ -5,6 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ +import { Path, getSystemPath, normalize } from '@angular-devkit/core'; import { Stats } from 'fs'; import { WebpackCompilerHost } from './compiler_host'; import { Callback, InputFileSystem, NodeWatchFileSystemInterface } from './webpack'; @@ -51,7 +52,7 @@ export class VirtualFileSystemDecorator implements InputFileSystem { this._inputFileSystem.readdir(path, callback); } - readFile(path: string, callback: Callback): void { + readFile(path: string, callback: Callback): void { const result = this._readFileSync(path); if (result) { callback(null, result); @@ -78,7 +79,7 @@ export class VirtualFileSystemDecorator implements InputFileSystem { return this._inputFileSystem.readdirSync(path); } - readFileSync(path: string): string | Buffer { + readFileSync(path: string): Buffer { const result = this._readFileSync(path); return result || this._inputFileSystem.readFileSync(path); @@ -105,26 +106,52 @@ export class VirtualFileSystemDecorator implements InputFileSystem { } export class VirtualWatchFileSystemDecorator extends NodeWatchFileSystem { - constructor(private _virtualInputFileSystem: VirtualFileSystemDecorator) { + constructor( + private _virtualInputFileSystem: VirtualFileSystemDecorator, + private _replacements?: Map | ((path: Path) => Path), + ) { super(_virtualInputFileSystem); } watch( - files: any, // tslint:disable-line:no-any - dirs: any, // tslint:disable-line:no-any - missing: any, // tslint:disable-line:no-any - startTime: any, // tslint:disable-line:no-any - options: any, // tslint:disable-line:no-any + files: string[], + dirs: string[], + missing: string[], + startTime: number | undefined, + options: {}, callback: any, // tslint:disable-line:no-any - callbackUndelayed: any, // tslint:disable-line:no-any + callbackUndelayed: (filename: string, timestamp: number) => void, ) { + const reverseReplacements = new Map(); + const reverseTimestamps = (map: Map) => { + for (const entry of Array.from(map.entries())) { + const original = reverseReplacements.get(entry[0]); + if (original) { + map.set(original, entry[1]); + map.delete(entry[0]); + } + } + + return map; + }; + + const newCallbackUndelayed = (filename: string, timestamp: number) => { + const original = reverseReplacements.get(filename); + if (original) { + this._virtualInputFileSystem.purge(original); + callbackUndelayed(original, timestamp); + } else { + callbackUndelayed(filename, timestamp); + } + }; + const newCallback = ( - err: any, // tslint:disable-line:no-any - filesModified: any, // tslint:disable-line:no-any - contextModified: any, // tslint:disable-line:no-any - missingModified: any, // tslint:disable-line:no-any - fileTimestamps: { [k: string]: number }, - contextTimestamps: { [k: string]: number }, + err: Error | null, + filesModified: string[], + contextModified: string[], + missingModified: string[], + fileTimestamps: Map, + contextTimestamps: Map, ) => { // Update fileTimestamps with timestamps from virtual files. const virtualFilesStats = this._virtualInputFileSystem.getVirtualFilesPaths() @@ -132,11 +159,60 @@ export class VirtualWatchFileSystemDecorator extends NodeWatchFileSystem { path: fileName, mtime: +this._virtualInputFileSystem.statSync(fileName).mtime, })); - virtualFilesStats.forEach(stats => fileTimestamps[stats.path] = +stats.mtime); - callback(err, filesModified, contextModified, missingModified, fileTimestamps, - contextTimestamps); + virtualFilesStats.forEach(stats => fileTimestamps.set(stats.path, +stats.mtime)); + callback( + err, + filesModified.map(value => reverseReplacements.get(value) || value), + contextModified.map(value => reverseReplacements.get(value) || value), + missingModified.map(value => reverseReplacements.get(value) || value), + reverseTimestamps(fileTimestamps), + reverseTimestamps(contextTimestamps), + ); + }; + + const mapReplacements = (original: string[]): string[] => { + if (!this._replacements) { + return original; + } + const replacements = this._replacements; + + return original.map(file => { + if (typeof replacements === 'function') { + const replacement = getSystemPath(replacements(normalize(file))); + if (replacement !== file) { + reverseReplacements.set(replacement, file); + } + + return replacement; + } else { + const replacement = replacements.get(normalize(file)); + if (replacement) { + const fullReplacement = getSystemPath(replacement); + reverseReplacements.set(fullReplacement, file); + + return fullReplacement; + } else { + return file; + } + } + }); }; - return super.watch(files, dirs, missing, startTime, options, newCallback, callbackUndelayed); + const watcher = super.watch( + mapReplacements(files), + mapReplacements(dirs), + mapReplacements(missing), + startTime, + options, + newCallback, + newCallbackUndelayed, + ); + + return { + close: () => watcher.close(), + pause: () => watcher.pause(), + getFileTimestamps: () => reverseTimestamps(watcher.getFileTimestamps()), + getContextTimestamps: () => reverseTimestamps(watcher.getContextTimestamps()), + }; } } diff --git a/packages/ngtools/webpack/src/webpack-input-host.ts b/packages/ngtools/webpack/src/webpack-input-host.ts new file mode 100644 index 000000000000..41201d1e88dc --- /dev/null +++ b/packages/ngtools/webpack/src/webpack-input-host.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { Path, PathFragment, fragment, getSystemPath, virtualFs } from '@angular-devkit/core'; +import { Stats } from 'fs'; +import { Observable, throwError } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { InputFileSystem } from './webpack'; + +// Host is used instead of ReadonlyHost due to most decorators only supporting Hosts +export class WebpackInputHost implements virtualFs.Host { + + constructor(public readonly inputFileSystem: InputFileSystem) { } + + get capabilities(): virtualFs.HostCapabilities { + return { synchronous: true }; + } + + write(_path: Path, _content: virtualFs.FileBufferLike) { + return throwError(new Error('Not supported.')); + } + + delete(_path: Path) { + return throwError(new Error('Not supported.')); + } + + rename(_from: Path, _to: Path) { + return throwError(new Error('Not supported.')); + } + + read(path: Path): Observable { + return new Observable(obs => { + // TODO: remove this try+catch when issue https://github.com/ReactiveX/rxjs/issues/3740 is + // fixed. + try { + const data = this.inputFileSystem.readFileSync(getSystemPath(path)); + obs.next(new Uint8Array(data).buffer as ArrayBuffer); + obs.complete(); + } catch (e) { + obs.error(e); + } + }); + } + + list(path: Path): Observable { + return new Observable(obs => { + // TODO: remove this try+catch when issue https://github.com/ReactiveX/rxjs/issues/3740 is + // fixed. + try { + const names = this.inputFileSystem.readdirSync(getSystemPath(path)); + obs.next(names.map(name => fragment(name))); + obs.complete(); + } catch (err) { + obs.error(err); + } + }); + } + + exists(path: Path): Observable { + return this.stat(path).pipe(map(stats => stats != null)); + } + + isDirectory(path: Path): Observable { + return this.stat(path).pipe(map(stats => stats != null && stats.isDirectory())); + } + + isFile(path: Path): Observable { + return this.stat(path).pipe(map(stats => stats != null && stats.isFile())); + } + + stat(path: Path): Observable { + return new Observable(obs => { + try { + const stats = this.inputFileSystem.statSync(getSystemPath(path)); + obs.next(stats); + obs.complete(); + } catch (e) { + if (e.code === 'ENOENT') { + obs.next(null); + obs.complete(); + } else { + obs.error(e); + } + } + }); + } + + watch(_path: Path, _options?: virtualFs.HostWatchOptions): null { + return null; + } +} diff --git a/packages/ngtools/webpack/src/webpack.ts b/packages/ngtools/webpack/src/webpack.ts index b857051ee20c..e9035fb7b9e6 100644 --- a/packages/ngtools/webpack/src/webpack.ts +++ b/packages/ngtools/webpack/src/webpack.ts @@ -22,12 +22,12 @@ export interface NormalModuleFactoryRequest { export interface InputFileSystem { stat(path: string, callback: Callback): void; readdir(path: string, callback: Callback): void; - readFile(path: string, callback: Callback): void; + readFile(path: string, callback: Callback): void; readJson(path: string, callback: Callback): void; readlink(path: string, callback: Callback): void; statSync(path: string): Stats; readdirSync(path: string): string[]; - readFileSync(path: string): string | Buffer; + readFileSync(path: string): Buffer; // tslint:disable-next-line:no-any readJsonSync(path: string): any; readlinkSync(path: string): string; diff --git a/tests/legacy-cli/e2e/tests/build/rebuild-replacements.ts b/tests/legacy-cli/e2e/tests/build/rebuild-replacements.ts new file mode 100644 index 000000000000..6d1fd83cf8f4 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/rebuild-replacements.ts @@ -0,0 +1,31 @@ +import { getGlobalVariable } from '../../utils/env'; +import { appendToFile } from '../../utils/fs'; +import { + execAndWaitForOutputToMatch, + killAllProcesses, + waitForAnyProcessOutputToMatch, +} from '../../utils/process'; + +const webpackGoodRegEx = /: Compiled successfully./; + +export default async function() { + if (process.platform.startsWith('win')) { + return; + } + + let error; + try { + await execAndWaitForOutputToMatch('ng', ['serve', '--prod'], webpackGoodRegEx); + + // Should trigger a rebuild. + await appendToFile('src/environments/environment.prod.ts', `console.log('PROD');`); + await waitForAnyProcessOutputToMatch(webpackGoodRegEx, 30000); + } catch (e) { + error = e; + } + + killAllProcesses(); + if (error) { + throw error; + } +}