diff --git a/packages/babel-cli/src/babel/dir.ts b/packages/babel-cli/src/babel/dir.ts index 2ab0652afbb3..238357cbacb4 100644 --- a/packages/babel-cli/src/babel/dir.ts +++ b/packages/babel-cli/src/babel/dir.ts @@ -21,7 +21,9 @@ export default async function ({ cliOptions, babelOptions, }: CmdOptions): Promise { - const filenames = cliOptions.filenames; + const absoluteFilePaths = cliOptions.filenames.map(name => + path.resolve(name), + ); async function write( src: string, @@ -150,6 +152,9 @@ export default async function ({ startTime = null; }, 100); + // Look at corresponding comment in file.js + if (cliOptions.watch) util.watchMode(); + if (!cliOptions.skipInitialBuild) { if (cliOptions.deleteDirOnStart) { util.deleteDir(cliOptions.outDir); @@ -173,44 +178,55 @@ export default async function ({ } if (cliOptions.watch) { - const chokidar = util.requireChokidar(); - - filenames.forEach(function (filenameOrDir: string): void { - const watcher = chokidar.watch(filenameOrDir, { - persistent: true, - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: 50, - pollInterval: 10, - }, - }); + let processing = 0; - // This, alongside with debounce, allows us to only log - // when we are sure that all the files have been compiled. - let processing = 0; - - ["add", "change"].forEach(function (type: string): void { - watcher.on(type, async function (filename: string) { - processing++; - if (startTime === null) startTime = process.hrtime(); - - try { - await handleFile( - filename, - filename === filenameOrDir - ? path.dirname(filenameOrDir) - : filenameOrDir, - ); - - compiledFiles++; - } catch (err) { - console.error(err); - } - - processing--; - if (processing === 0 && !cliOptions.quiet) logSuccess(); - }); - }); - }); + util.onDependencyFileChanged(async (changedFilePath: string) => { + if ( + !util.isCompilableExtension(changedFilePath, cliOptions.extensions) && + // See comment on corresponding code in file.js + !absoluteFilePaths.includes(changedFilePath) + ) { + return; + } + processing++; + if (startTime === null) startTime = process.hrtime(); + + /** + * The relative path from @var base to @var changedFilePath + * will be path of @var changedFilePath in the output directory. + */ + let base = null; + for (const filePath of absoluteFilePaths) { + if (changedFilePath === filePath) { + // Case: "babel --watch src/bar/foo.js --out-dir dist" + // We want src/bar/foo.js --> dist/foo.js + base = path.dirname(changedFilePath); + break; + } else if (util.isChildPath(changedFilePath, filePath)) { + // Case: "babel --watch src/ --out-dir dist" + // src/foo/bar.js changes + // We want src/foo/bar.js --> dist/foo/bar.js + base = filePath; + break; + } + } + + if (base === null) { + throw new Error( + `path: ${changedFilePath} was not equal to/a child path of any of these paths: ${absoluteFilePaths}`, + ); + } + + try { + await handleFile(changedFilePath, base); + compiledFiles++; + } catch (err) { + console.error(err); + } + + processing--; + if (processing === 0 && !cliOptions.quiet) logSuccess(); + }, false); + util.watchFiles(absoluteFilePaths); } } diff --git a/packages/babel-cli/src/babel/file.ts b/packages/babel-cli/src/babel/file.ts index e05f664979ad..17685e8fc82f 100644 --- a/packages/babel-cli/src/babel/file.ts +++ b/packages/babel-cli/src/babel/file.ts @@ -193,40 +193,37 @@ export default async function ({ } async function files(filenames: Array): Promise { + // We need to set watch mode before the initial compilation + // so external dependencies are registered during the first compilation pass. + if (cliOptions.watch) util.watchMode(); if (!cliOptions.skipInitialBuild) { await walk(filenames); } if (cliOptions.watch) { - const chokidar = util.requireChokidar(); - chokidar - .watch(filenames, { - disableGlobbing: true, - persistent: true, - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: 50, - pollInterval: 10, - }, - }) - .on("all", function (type: string, filename: string): void { - if ( - !util.isCompilableExtension(filename, cliOptions.extensions) && - !filenames.includes(filename) - ) { - return; - } + util.onDependencyFileChanged((filename: string | null) => { + if ( + filename !== null && + !util.isCompilableExtension(filename, cliOptions.extensions) && + // Used in the case: babel --watch foo.ts --out-file compiled.js + // In this, case ".ts" is not a compilable extension (since the user didn't pass + // the --extensions flag), but, we still want to watch "foo.ts" anyway. + !filenames.includes(filename) + ) { + return; + } - if (type === "add" || type === "change") { - if (cliOptions.verbose) { - console.log(type + " " + filename); - } + if (cliOptions.verbose) { + if (filename === null) { + console.log(`recompiling: external dependency changed`); + } else console.log(`compiling: ${filename}`); + } - walk(filenames).catch(err => { - console.error(err); - }); - } + walk(filenames).catch(err => { + console.error(err); }); + }, true); + util.watchFiles(filenames); } } diff --git a/packages/babel-cli/src/babel/util.ts b/packages/babel-cli/src/babel/util.ts index 992a77d4a2ea..c41c629c594c 100644 --- a/packages/babel-cli/src/babel/util.ts +++ b/packages/babel-cli/src/babel/util.ts @@ -4,6 +4,10 @@ import path from "path"; import fs from "fs"; import { createRequire } from "module"; +/** + * Set the file permissions of dest to the file permissions + * of src. + */ export function chmod(src: string, dest: string): void { try { fs.chmodSync(dest, fs.statSync(src).mode); @@ -60,7 +64,7 @@ const CALLER = { name: "@babel/cli", }; -export function transform( +export async function transform( filename: string, code: string, opts: any, @@ -71,26 +75,132 @@ export function transform( filename, }; - return new Promise((resolve, reject) => { - babel.transform(code, opts, (err, result) => { - if (err) reject(err); - else resolve(result); - }); - }); + const result = await babel.transformAsync(code, opts); + if (isWatchMode) watchNewExternalDependencies(filename); + return result; } -export function compile(filename: string, opts: any | Function): Promise { +export async function compile( + filename: string, + opts: any | Function, +): Promise { opts = { ...opts, caller: CALLER, }; - return new Promise((resolve, reject) => { - babel.transformFile(filename, opts, (err, result) => { - if (err) reject(err); - else resolve(result); + const result = await babel.transformFileAsync(filename, opts); + if (isWatchMode) watchNewExternalDependencies(filename); + return result; +} + +let isWatchMode = false; + +export function watchMode() { + isWatchMode = true; +} + +/** + * Check if @param child is a child of @param parent + * Both paths must be absolute/resolved. (No "..") + */ +export function isChildPath(child: string, parent: string): boolean { + return ( + child.length > parent.length + 1 && child.startsWith(parent + path.sep) + ); +} + +function subtract(minuend: Set, subtrahend: Set): string[] { + const diff = []; + for (const e of minuend) { + if (!subtrahend.has(e)) diff.push(e); + } + return diff; +} + +/** + * Register new external dependencies with the file system + * watcher (chokidar). + */ +const watchNewExternalDependencies = (() => { + let prevDeps = null; + return (filePath: string) => { + // make the file path absolute because + // dependencies are registered with absolute file paths + filePath = path.resolve(filePath); + const prevDepsForFile = + prevDeps === null ? new Set() : prevDeps.get(filePath) || new Set(); + const newDeps = babel.getDependencies(); + const newDepsForFile = newDeps.get(filePath) || new Set(); + const unwatchedDepsForFile = subtract(newDepsForFile, prevDepsForFile); + for (const dep of unwatchedDepsForFile) { + watchFiles(dep); + } + prevDeps = newDeps; + }; +})(); + +const getWatcher = (() => { + // Use a closure to ensure the file watcher is only created once + // and never re-assigned. A const global variable isn't sufficient + // because we only want to create the file watcher if the user passes + // the --watch option, and a const variable must always be initialized. + let watcher = undefined; + return () => { + if (watcher) return watcher; + const { FSWatcher } = requireChokidar(); + watcher = new FSWatcher({ + disableGlobbing: true, + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 50, + pollInterval: 10, + }, }); - }); + return watcher; + }; +})(); + +/** + * Call @param callback whenever a dependency (source file)/ + * external dependency (non-source file) changes. + * + * Handles mapping external dependencies to their corresponding + * dependencies. + */ +export function onDependencyFileChanged( + callback: (filename_: string | null) => Promise, + sourceFilesAreCompiledIntoASingleFile: boolean, +): void { + /** + * + * @param filePath The path of a file that has changed. + * It will never be a path to a directory. + * */ + async function onFileChanged(filePath: string) { + // see corresponding line in registerNewExternalDependencies + filePath = path.resolve(filePath); + const externalFileDeps = babel.getExternalDependencies(); + if (externalFileDeps.has(filePath)) { + if (sourceFilesAreCompiledIntoASingleFile) { + // When using --out-file, Babel traverses all the files every time + // so there's no point in calling the callback multiple times. The callback + // for --out-file knows to recompile no matter what if it receives null. + return await callback(null); + } else { + for (const dependent of externalFileDeps.get(filePath)) { + await callback(dependent); + } + } + } + return await callback(filePath); + } + ["add", "change"].forEach(type => getWatcher().on(type, onFileChanged)); +} + +export function watchFiles(filenameOrFilenames: string | string[]): void { + getWatcher().add(filenameOrFilenames); } export function deleteDir(path: string): void { diff --git a/packages/babel-core/src/config/helpers/config-api.ts b/packages/babel-core/src/config/helpers/config-api.ts index 16fe7040a28e..54198b8b864d 100644 --- a/packages/babel-core/src/config/helpers/config-api.ts +++ b/packages/babel-core/src/config/helpers/config-api.ts @@ -9,6 +9,8 @@ import type { SimpleType, } from "../caching"; +import path from "path"; + import type { CallerMetadata } from "../validation/options"; import * as Context from "../cache-contexts"; @@ -33,6 +35,10 @@ export type ConfigAPI = { async: () => boolean; assertVersion: typeof assertVersion; caller?: CallerFactory; + addExternalDependency: ( + externalDependencyFileName: string, + dependentFileName: string, + ) => void; }; export type PresetAPI = { @@ -43,6 +49,64 @@ export type PluginAPI = { assumption: AssumptionFunction; } & PresetAPI; +/** + * "dependencies" are source files that are directly compiled as part of the + * normal compilation process. + * + * "externalDependencies" are non-source files that should trigger a recompilation + * of some source file when they are changed. An example is a markdown file that + * is inlined into a source file as part of a Babel plugin. + */ + +const dependencies = new Map>(); +const externalDependencies = new Map>(); +/** + * @returns a map of source file paths to their external dependencies. + */ +export const getDependencies = () => dependencies; +/** + * @returns a map of external dependencies to the source file paths + * that depend on them. + */ +export const getExternalDependencies = () => externalDependencies; + +/** + * Indicate that Babel should recompile the file at @param dependentFilePath when + * the file at @param externalDependencyPath changes. @param externalDependencyPath can + * be any arbitrary file. NOTE: This currently only works with @babel/cli's --watch flag. + * @param externalDependencyPath Must be either + * absolute or relative to @param currentFilePath. + * @param dependentFilePath Must be absolute or relative to the directory Babel was launched from. + * For plugin authors, this is usually the current file being processed by Babel/your plugin. + * It can be found at: "state.file.opts.filename". + */ +function addExternalDependency( + externalDependencyPath: string, + dependentFilePath: string, +): void { + /** + * Inside the dependency maps we want to store all paths as absolute because we can + * derive a relative path from an absolute path but not vice-versa. Also, Webpack's + * `addDependency` requires absolute paths. + */ + const currentFileAbsolutePath = path.resolve(dependentFilePath); + const currentFileDir = path.dirname(currentFileAbsolutePath); + const externalDependencyAbsolutePath = path.isAbsolute(externalDependencyPath) + ? externalDependencyPath + : path.join(currentFileDir, externalDependencyPath); + + if (!dependencies.has(currentFileAbsolutePath)) { + dependencies.set(currentFileAbsolutePath, new Set()); + } + if (!externalDependencies.has(externalDependencyAbsolutePath)) { + externalDependencies.set(externalDependencyAbsolutePath, new Set()); + } + dependencies.get(currentFileAbsolutePath).add(externalDependencyAbsolutePath); + externalDependencies + .get(externalDependencyAbsolutePath) + .add(currentFileAbsolutePath); +} + export function makeConfigAPI( cache: CacheConfigurator, ): ConfigAPI { @@ -72,6 +136,7 @@ export function makeConfigAPI( async: () => false, caller, assertVersion, + addExternalDependency, }; } diff --git a/packages/babel-core/src/index.ts b/packages/babel-core/src/index.ts index 8abd7cc17489..b6f300806165 100644 --- a/packages/babel-core/src/index.ts +++ b/packages/babel-core/src/index.ts @@ -29,6 +29,11 @@ export { loadOptionsAsync, } from "./config"; +export { + getDependencies, + getExternalDependencies, +} from "./config/helpers/config-api"; + export { transform, transformSync, transformAsync } from "./transform"; export { transformFile,