UNPKG

12.3 kBJavaScriptView Raw
1'use strict';
2const sysPath = require('universal-path');
3const getRelativePath = sysPath.relative;
4const logger = require('loggy');
5const chokidar = require('chokidar');
6const debug = require('debug')('brunch:watch');
7const speed = require('since-app-start');
8const serveBrunch = require('serve-brunch');
9const cpus = require('os').cpus().length;
10const deppack = require('deppack');
11const install = require('deps-install');
12
13const workers = require('./workers'); // close, init
14const write = require('./fs_utils/write');
15const ignored = require('./fs_utils/is_ignored');
16const FileList = require('./fs_utils/file_list');
17const pipeline = require('./fs_utils/pipeline');
18
19const application = require('./utils/config'); // loadConfig, install
20const plugins = require('./utils/plugins');
21const helpers = require('./utils/helpers'); // asyncFilter, flatten, generateCompilationLog, getCompilationProgress
22
23const promisifyHook = plugin => {
24 const hook = plugin.preCompile;
25 if (!hook.length) return;
26 plugin.preCompile = () => new Promise(resolve => {
27 hook.call(plugin, resolve);
28 });
29};
30
31const mergeHooks = (plugins, config) => {
32 return Object.keys(plugins).reduce((mergedHooks, name) => {
33 const allHooks = plugins[name].concat(config);
34 if (name === 'preCompile') {
35 allHooks.forEach(promisifyHook);
36 }
37
38 mergedHooks[name] = function() {
39 // => has lexical `arguments`
40 return allHooks.map(plugin => {
41 return plugin[name].apply(plugin, arguments);
42 });
43 };
44
45 return mergedHooks;
46 }, {});
47};
48
49const filterNonExistentPaths = paths => {
50 return Promise.all(paths.map(helpers.fsExists)).then(values => {
51 // watched files
52 return paths.filter((path, index) => values[index]);
53 });
54};
55
56// Filter paths that exist and watch them with `chokidar` package.
57const getWatchedPaths = config => {
58 const configs = config.paths.allConfigFiles;
59 const pkg = config.packageInfo;
60 const getFiles = pkgs => helpers.flatten(pkgs.components.map(c => c.files));
61 const watched = config.paths.watched.concat(configs, getFiles(pkg.npm), getFiles(pkg.bower));
62 return filterNonExistentPaths(watched);
63};
64
65const setDefaultJobsCount = jobs => {
66 const MAX_JOBS = 32;
67 const env = process.env.BRUNCH_JOBS;
68 if (!jobs && !env || jobs === true) return;
69 // Mitigates Intel Hyperthreading.
70 const str = jobs || env || cpus / 2;
71 const int = Math.round(str);
72 return isNaN(int) ||
73 int < 1 ||
74 int > MAX_JOBS ? 1 : int;
75};
76
77/* persistent - Boolean: should brunch build the app only once or watch it?
78 * options - Object: {configPath, optimize, server, port}.
79 * Only configPath is required.
80 * onCompile - Function that will be executed after every successful
81 * compilation. May receive an array of `fs_utils.GeneratedFile`.
82 *
83 * this.config is an application config.
84 * this._startTime is a mutable timestamp that represents latest compilation
85 * start time. It is `null` when there are no compilations.
86 */
87class BrunchWatcher {
88 constructor(persistent, options, onCompile) {
89 speed.profile('Created BrunchWatcher');
90 this._constructorOptions = arguments;
91 this._startTime = Date.now() - speed.sinceStart;
92 this._isFirstRun = true;
93 this._onReload = options._onReload;
94 options.jobs = setDefaultJobsCount(options.jobs);
95
96 if (!persistent) {
97 process.on('exit', previousCode => {
98 const currentCode = logger.errorHappened ? 1 : previousCode;
99 process.exit(currentCode);
100 });
101 }
102
103 application.loadConfig(persistent, options)
104 .then(cfg => {
105 this.config = cfg;
106 if (options.jobs > 1) {
107 workers.init(options, cfg);
108 }
109 return Promise.all([
110 getWatchedPaths(cfg._normalized),
111 serveBrunch.serve(cfg.server),
112 plugins(cfg, options.dependencies),
113 ]);
114 })
115 .then(res => {
116 const cfg = this.config;
117 const watchedPaths = res[0];
118 this.server = res[1];
119 const hooks = res[2].hooks;
120 hooks.onCompile.push({onCompile});
121 this.hooks = mergeHooks(hooks, cfg.hooks);
122 const plugins = this.plugins = res[2].plugins;
123
124 pipeline.setPlugins(plugins.all);
125 pipeline.setNpmCompilers(cfg.npm.compilers);
126 deppack.setPlugins(plugins, cfg.npm.compilers);
127
128 return Promise.all(this.hooks.preCompile()).then(() => {
129 this.initWatcher(watchedPaths);
130 this.initCompilation();
131 });
132 })
133 .catch(error => {
134 if (typeof error === 'string') {
135 // TODO: Title - init error.
136 logger.error(error);
137 } else {
138 const text = error.code === 'CFG_LOAD_FAILED' ?
139 error.message :
140 `Initialization error - ${error.message.trim()}`;
141 // TODO: Title - init error.
142 logger.error(text, error);
143 }
144 process.exit(1);
145 });
146 }
147
148 initCompilation() {
149 const cfg = this.config;
150
151 this.fileList = new FileList(cfg);
152 this.fileList.on('ready', () => {
153 if (this._startTime) {
154 this.compile();
155 }
156 });
157
158 const rootPath = cfg.paths.root;
159 this.plugins.includes.forEach(path => {
160 const relPath = getRelativePath(rootPath, path);
161
162 // Emit `change` event for each file that is included with plugins.
163 this.startCompilation('change', relPath);
164 cfg.npm.static.push(relPath);
165 });
166
167 Object.freeze(cfg.npm.static);
168
169 if (!cfg.persistent) return;
170
171 const emptyFileListInterval = 1000;
172 const checkNothingToCompile = () => {
173 if (!this.fileList.hasFiles) {
174 logger.warn(`Nothing to compile. Most likely you don't have any source files yet, in which case, go ahead and create some!`);
175 }
176 };
177
178 clearTimeout(this.nothingToCompileTimer);
179 this.nothingToCompileTimer = setTimeout(checkNothingToCompile, emptyFileListInterval);
180
181 if (cfg.stdin) {
182 process.stdin.on('end', () => process.exit(0));
183 process.stdin.resume();
184 }
185 }
186
187 exitProcessFromFile(reasonFile) {
188 logger.info(`Detected removal of ${reasonFile}\nExiting.`);
189 process.exit(0);
190 }
191
192 initWatcher(watchedPaths) {
193 const isDebug = !!process.env.DEBUG;
194 const config = this.config._normalized;
195 const paths = config.paths;
196 const isConfig = path => paths.allConfigFiles.includes(path);
197
198 speed.profile('Loaded watcher');
199 this.watcher = chokidar.watch(watchedPaths, Object.assign({
200 ignored,
201 persistent: config.persistent,
202 }, config.watcher))
203 .on('error', error => {
204 // TODO: Watch error.
205 logger.error(error);
206 })
207 .on('add', absPath => {
208 if (isDebug) debug(`add ${absPath}`);
209
210 const path = getRelativePath(paths.root, absPath);
211 if (isConfig(path)) return; // Pass for the initial files.
212 this.startCompilation('change', path);
213 })
214 .on('change', absPath => {
215 if (isDebug) debug(`change ${absPath}`);
216
217 const path = getRelativePath(paths.root, absPath);
218 if (path === paths.packageConfig) {
219 this.restartBrunch('package');
220 } else if (path === paths.bowerConfig) {
221 this.restartBrunch('bower');
222 } else if (isConfig(path)) {
223 this.restartBrunch();
224 } else {
225 this.startCompilation('change', path);
226 }
227 })
228 .on('unlink', absPath => {
229 if (isDebug) debug(`unlink ${absPath}`);
230
231 const path = getRelativePath(paths.root, absPath);
232 if (isConfig(path)) return this.exitProcessFromFile(path);
233 this.startCompilation('unlink', path);
234 })
235 .once('ready', () => {
236 speed.profile('Watcher is ready');
237 this.watcherIsReady = true;
238 });
239 }
240
241 restartBrunch(pkgType) {
242 const restart = () => {
243 // we need this to keep compatibility with global `brunch` binaries
244 // from older versions which didn't create a child process
245 if (process.send && process.env.BRUNCH_FORKED_PROCESS === 'true') {
246 process.send('reload');
247 } else {
248 const opts = this._constructorOptions;
249 const newWatcher = new BrunchWatcher(opts[0], opts[1], opts[2]);
250 if (this._onReload) this._onReload(newWatcher);
251 return newWatcher;
252 }
253 };
254
255 const rootPath = this.config.paths.root;
256 const reWatch = () => {
257 logger.info('Reloading watcher...');
258 this.hooks.teardown();
259 const server = this.server;
260 if (server && typeof server.close === 'function') {
261 return server.close(restart);
262 }
263 return restart();
264 };
265
266 clearTimeout(this.nothingToCompileTimer);
267 this.fileList.dispose();
268 this.watcher.close();
269 workers.close();
270
271 return install({rootPath, pkgType}).then(reWatch, reWatch);
272 }
273
274 compile() {
275 const startTime = this._endCompilation();
276 const config = this.config;
277 const joinConfig = config._normalized.join;
278 const fileList = this.fileList;
279 const watcher = this.watcher;
280 const optimizers = this.plugins.optimizers;
281
282 const assetErrors = fileList.assetErrors;
283 if (assetErrors.length) {
284 assetErrors.forEach(error => {
285 // TODO: Title - asset processing error
286 logger.error(error);
287 });
288 return;
289 }
290
291 // Determine which files has been changed,
292 // create new `fs_utils.GeneratedFile` instances and write them.
293 write(fileList, config, joinConfig, optimizers, startTime).then(data => {
294 const generatedFiles = data.changed;
295 const disposed = data.disposed;
296 fileList.removeDisposedFiles();
297 this._endBundle();
298 const assets = fileList.copiedAfter(startTime);
299 logger.info(helpers.generateCompilationLog(
300 startTime, assets, generatedFiles, disposed
301 ));
302
303 // Pass `fs_utils.GeneratedFile` instances to callbacks.
304 // Does not block the execution.
305 this.hooks.onCompile(generatedFiles, assets);
306 }, error => {
307 this._endBundle();
308 if (error.code === 'WRITE_FAILED') return; // Ignore write errors as they are logged already
309
310 if (!Array.isArray(error)) error = [error];
311
312 // Compilation, optimization, linting errors are logged here.
313 error.forEach(subError => {
314 // TODO: Title - pipeline error.
315 logger.error(subError);
316 });
317
318 const canTryRecover = error.find(err => err.code === 'DEPS_RESOLVE_INSTALL');
319 if (canTryRecover) {
320 logger.warn('Attempting to recover from failed NPM requires by running `npm install`...');
321 this.restartBrunch('package');
322 }
323 }).then(() => {
324 if (!this.watcherIsReady) return;
325 // If it’s single non-continuous build, close file watcher and
326 // exit process with correct exit code.
327 if (!config.persistent) {
328 watcher.close();
329 workers.close();
330 }
331 fileList.initial = false;
332 }).catch(logger.error);
333 }
334
335 _createProgress() {
336 if (this._compilationProgress || process.env.DEBUG) return false;
337 const passedTime = this._isFirstRun && speed.sinceStart;
338 this._compilationProgress = helpers.getCompilationProgress(
339 passedTime, logger.info
340 );
341 }
342
343 // Set start time of last compilation to current time.
344 // Returns number.
345 startCompilation(type, path) {
346 this.fileList.emit(type, path);
347 this._createProgress();
348 if (this._isFirstRun) {
349 speed.profile('Starting compilation');
350 this._isFirstRun = false;
351 }
352 if (!this._startTime) this._startTime = Date.now();
353 return this._startTime;
354 }
355
356 // Get last compilation start time and reset the state.
357 // Returns number.
358 _endCompilation() {
359 const start = this._startTime;
360 this._startTime = null;
361 return start;
362 }
363
364 _endBundle() {
365 if (!this._compilationProgress) return;
366 this._compilationProgress();
367 this._compilationProgress = null;
368 }
369}
370
371const watch = (persistent, path, options, onCompile) => {
372 if (!onCompile) onCompile = () => {};
373
374 // If path isn't provided (by CLI).
375 if (path) {
376 if (typeof path === 'string') {
377 process.chdir(path);
378 } else {
379 if (typeof options === 'function') onCompile = options;
380 options = path;
381 }
382 }
383 return new BrunchWatcher(persistent, options, onCompile);
384};
385
386module.exports = watch;