Home Manual Reference Source Test

src/tasks/watch.js

import { join } from 'path';
import sane from 'sane';
import browserSync from 'browser-sync';
import Logger from 'gulplog';
import ServerWatcher from '../lib/server/Watcher';
import { delay } from '../lib/helpers/async';
import { handleTaskError } from '../lib/helpers/tasks';
import ProjectConfig from '../config/ProjectConfig';
import { validateDirectoryExists } from '../util/fs';
import { setupContext } from '../hooks/hooks';
import checkAtserver from '../hooks/check-atserver';
import checkServerscripts from '../hooks/check-serverscripts';
import { performPull } from './pull';
import { performPush } from './push';

/**
 * The task executed when running `atscm watch`.
 */
export class WatchTask {
  /**
   * Creates a new watch task instance. Also creates a new Browsersync instance.
   */
  constructor() {
    /**
     * The Browsersync instance used.
     * @type {events~Emitter}
     */
    this.browserSyncInstance = browserSync.create();

    /**
     * If the task is currently pulling.
     * @type {boolean}
     */
    this._pulling = false;

    /**
     * If the task is currently pushing.
     * @type {boolean}
     */
    this._pushing = false;

    /**
     * Timestamp of the last pull
     * @type {number}
     */
    this._lastPull = 0;

    /**
     * The {@link NodeId} of the last push.
     * @type {?NodeId}
     */
    this._lastPushed = null;
  }

  /**
   * The directory to watch.
   * @type {string}
   */
  get directoryToWatch() {
    return './src';
  }

  /**
   * Waits for a watcher (which can actually be any kind of {@link events~Emitter}) to emit a
   * "ready" event.
   * @param {events~Emitter} watcher The watcher to wait for.
   * @return {Promise<events~Emitter, Error>} Fulfilled with the set up watcher or rejected with the
   * watcher error that occurred while waiting for it to get ready.
   */
  _waitForWatcher(watcher) {
    return new Promise((resolve, reject) => {
      watcher.on('error', (err) => reject(err));
      watcher.on('ready', () => resolve(watcher));
    });
  }

  /**
   * Starts a file watcher for the directory {@link WatchTask#directoryToWatch}.
   * @return {Promise<sane~Watcher, Error>} Fulfilled with the file watcher once it is ready or
   * rejected with the error that occurred while starting the watcher.
   */
  startFileWatcher() {
    return validateDirectoryExists(this.directoryToWatch)
      .catch((err) => {
        if (err.code === 'ENOENT') {
          Logger.info(`Create a directory at ${this.directoryToWatch} or run \`atscm pull\` first`);

          Object.assign(err, {
            message: `Directory ${this.directoryToWatch} does not exist`,
          });
        }

        throw err;
      })
      .then(() =>
        this._waitForWatcher(
          sane(this.directoryToWatch, {
            glob: '**/*.*',
            watchman: process.platform === 'darwin',
          })
        )
      );
  }

  /**
   * Starts a watcher that watches the atvise server for changes.
   * @return {Promise<Watcher, Error>} Fulfilled with the server watcher once it is ready or
   * rejected with the error that occurred while starting the watcher.
   */
  startServerWatcher() {
    return this._waitForWatcher(new ServerWatcher());
  }

  /**
   * Initializes {@link WatchTask#browserSyncInstance}.
   * @param {Object} options The options to pass to browsersync.
   * @see https://browsersync.io/docs/options
   */
  initBrowserSync(options) {
    this.browserSyncInstance.init(
      Object.assign(
        {
          proxy: `${ProjectConfig.host}:${ProjectConfig.port.http}`,
          ws: true,
          // logLevel: 'debug', FIXME: Use log level specified in cli options
          // logPrefix: '',
        },
        options
      )
    );

    /* bs.logger.logOne = function(args, msg, level, unprefixed) {
      args = args.slice(2);

      if (this.config.useLevelPrefixes && !unprefixed) {
        msg = this.config.prefixes[level] + msg;
      }

      msg = this.compiler.compile(msg, unprefixed);

      args.unshift(msg);

      Logger[level](format(...args));

      this.resetTemps();

      return this;
    }; */
  }

  /**
   * Prints an error that happened while handling a change.
   * @param {string} contextMessage Describes the currently run action.
   * @param {Error} err The error that occured.
   */
  printTaskError(contextMessage, err) {
    try {
      handleTaskError(err);
    } catch (refined) {
      Logger.error(contextMessage, refined.message, refined.stack);
    }
  }

  /**
   * Handles a file change.
   * @param {string} path The path of the file that changed.
   * @param {string} root The root of the file that changed.
   * @return {Promise<boolean>} Resolved with `true` if the change triggered a push operation,
   * with `false` otherwise.
   */
  handleFileChange(path, root) {
    if (this._handlingChange) {
      Logger.debug('Ignoring', path, 'changed');
      return Promise.resolve(false);
    }

    this._handlingChange = true;
    Logger.info(path, 'changed');

    return performPush(join(root, path), { singleNode: true })
      .catch((err) => this.printTaskError('Push failed', err))
      .then(async () => {
        this.browserSyncInstance.reload();

        await delay(500);

        this._handlingChange = false;
      });
  }

  /**
   * Handles an atvise server change.
   * @param {ReadStream.ReadResult} readResult The read result of the modification.
   * @return {Promise<boolean>} Resolved with `true` if the change triggered a pull operation,
   * with `false` otherwise.
   */
  handleServerChange(readResult) {
    if (this._handlingChange) {
      Logger.debug('Ignoring', readResult.nodeId.value, 'changed');
      return Promise.resolve(false);
    }

    this._handlingChange = true;
    Logger.info(readResult.nodeId.value, 'changed');

    return performPull([readResult.nodeId], { recursive: false })
      .catch((err) => this.printTaskError('Pull failed', err))
      .then(async () => {
        this.browserSyncInstance.reload();

        await delay(500);

        this._handlingChange = false;
      });
  }

  /**
   * Starts the file and server watchers, initializes Browsersync and registers change event
   * handlers.
   * @param {Object} [options] The options to pass to browsersync.
   * @param {boolean} [options.open=true] If the browser should be opened once browsersync is up.
   * @return {Promise<{ serverWatcher: Watcher, fileWatcher: sane~Watcher }, Error>} Fulfilled once
   * all watchers are set up and Browsersync was initialized.
   */
  run({ open = true } = {}) {
    return Promise.all([this.startFileWatcher(), this.startServerWatcher()]).then(
      ([fileWatcher, serverWatcher]) => {
        this.browserSyncInstance.emitter.on('service:running', () => {
          Logger.info('Watching for changes...');
          Logger.debug('Press Ctrl-C to exit');
        });

        fileWatcher.on('change', this.handleFileChange.bind(this));
        serverWatcher.on('change', this.handleServerChange.bind(this));

        this.initBrowserSync({ open });

        return { fileWatcher, serverWatcher };
      }
    );
  }
}

/**
 * The gulp task invoced when running `atscm watch`.
 * @param {Object} options The options to pass to the watch task, see {@link WatchTask#run} for
 * available options.
 * @return {Promise<{ serverWatcher: Watcher, fileWatcher: sane~Watcher }, Error>} Fulfilled once
 * all watchers are set up and Browsersync was initialized.
 */
export default async function watch(options) {
  const context = setupContext();
  await checkAtserver(context);
  await checkServerscripts(context);

  return new WatchTask().run(options);
}

watch.description = 'Watch local files and atvise server nodes to trigger pull/push on change';