Source: index.js

/**
 * @copyright Copyright 2016 Kevin Locke <kevin@kevinlocke.name>
 * @license MIT
 */

'use strict';

var DiscardStream = require('./lib/discard-stream');
var EmptyStream = require('./lib/empty-stream');
var debug = require('debug')('stdio-context');
var inspect = require('util').inspect;

/** Number of StdioContext instances which have been created.
 * Used to assign a serial number to each instance to ease debugging.
 * @private
 */
var instanceCount = 0;

/** Stack of StdioContext states in the order that they were entered.
 * @const
 * @private {!Array<!StdioContextState>}
 */
var contextStack = [];

/** State information for {@link StdioContext}.
 *
 * @typedef {{
 *   context: !StdioContext,
 *   exited: boolean,
 *   beforeEnter: !{
 *     console: ObjectPropertyDescriptor|undefined,
 *     stdin: ObjectPropertyDescriptor|undefined,
 *     stdout: ObjectPropertyDescriptor|undefined,
 *     stderr: ObjectPropertyDescriptor|undefined
 *   },
 *   afterEnter: !{
 *     console: ObjectPropertyDescriptor|undefined,
 *     stdin: ObjectPropertyDescriptor|undefined,
 *     stdout: ObjectPropertyDescriptor|undefined,
 *     stderr: ObjectPropertyDescriptor|undefined
 *   }
 * }} StdioContextState
 * @property {!StdioContext} context Context for which this state was added.
 * @property {boolean=} exited Has {@link StdioContext#exit()} been called for
 * this state?
 * @property {!Object} beforeEnter Stdio properties changed by the context, as
 * they existed when {@link StdioContext#enter()} was called.
 * @property {!Object} afterEnter Stdio properties changed by the context, as
 * they existed when {@link StdioContext#enter()} returned.
 * @private
 */
// var StdioContextState;

/** util.inspect wrapper for use with debug.
 *
 * Note:  Use https://github.com/visionmedia/debug/pull/266 if accepted
 *
 * @param {*} object Value to inspect.
 * @param {Object} options Inspect options.
 * @return {string} String representation of object.
 * @private
 */
/* istanbul ignore next */
function debugInspect(object, options) {
  if (!debug.enabled) { return ''; }
  // From https://github.com/visionmedia/debug/blob/2.2.0/node.js#L72-L73
  return inspect(object, {
    showHidden: options && options.showHidden,
    depth: options && options.depth,
    colors: options && options.colors !== undefined ? options.colors :
      debug.useColors,
    customInspect: options && options.customInspect
  }).replace(/\s*\n\s*/g, ' ');
}

/** Sets the value of a property, preserving the previous descriptor attributes
 * of the property and returning the descriptor for the previous value.
 *
 * Note:  process.{stdin,stdout,stderr} are getters which initialize the
 * streams on first access.  We try to avoid unnecessary initialization.
 *
 * global.console is a getter which aliases the console module.  We want to
 * preserve that behavior.
 *
 * @param {!Object} obj Object on which to replace the property.
 * @param {string} prop Name of the property to replace.
 * @param {*} value Value of the replacement property.
 * @return {!ObjectPropertyDescriptor|undefined} The descriptor for the
 * property before the new value was set.
 * @private
 */
function replaceProperty(obj, prop, value) {
  var prevDescriptor = Object.getOwnPropertyDescriptor(obj, prop);
  Object.defineProperty(obj, prop, {
    configurable: true,
    enumerable: prevDescriptor.enumerable,
    writable: prevDescriptor.writable || Boolean(prevDescriptor.set),
    value: value
  });
  return prevDescriptor;
}

/** Checks if a property has a given property descriptor.
 * @param {!Object} obj Object on which to compare the property.
 * @param {string} prop Name of the property to compare.
 * @param {ObjectPropertyDescriptor} descriptor Descriptor against which to
 * compare.
 * @return {boolean} <code>true</code> if <code>descriptor</code> is equal to
 * the descriptor for <code>prop</code>.  <code>false</code> otherwise.
 * @private
 */
function hasOwnPropertyDescriptor(obj, prop, descriptor) {
  var propDescriptor = Object.getOwnPropertyDescriptor(obj, prop);
  return propDescriptor &&
    propDescriptor.configurable === descriptor.configurable &&
    propDescriptor.enumerable === descriptor.enumerable &&
    propDescriptor.get === descriptor.get &&
    propDescriptor.set === descriptor.set &&
    propDescriptor.value === descriptor.value &&
    propDescriptor.writable === descriptor.writable;
}

/** Restores the state of the stdio streams from before a context was entered.
 * @param {!StdioContextState} contextState State to restore.
 * @private
 */
function restoreState(contextState) {
  var context = contextState.context;
  var opts = context._options;
  var afterEnter = contextState.afterEnter;
  var beforeEnter = contextState.beforeEnter;

  debug('restoring state from StdioContext %s', context._name,
      debugInspect(opts, {depth: 0}));

  function restoreProp(obj, prop) {
    if (beforeEnter[prop]) {
      if (opts.overwrite ||
          hasOwnPropertyDescriptor(obj, prop, afterEnter[prop])) {
        Object.defineProperty(obj, prop, beforeEnter[prop]);
      } else if (opts.strict) {
        throw new Error(prop + ' modified outside StdioContext');
      }
    }
  }

  restoreProp(process, 'stdin');
  restoreProp(process, 'stdout');
  restoreProp(process, 'stderr');
  restoreProp(global, 'console');
}

/** Options for {@link StdioContext}.
 *
 * @typedef {{
 *   overwrite: boolean|undefined,
 *   stdin: stream.Readable|undefined,
 *   stdout: stream.Writable|undefined,
 *   stderr: stream.Writable|undefined,
 *   strict: boolean|undefined
 * }} StdioContextOptions
 * @property {boolean=} overwrite Overwrite the current stdio stream value when
 * exiting?
 * @property {stream.Readable=} stdin Stream which will act as
 * <code>stdin</code>.  <code>null</code> for empty input.
 * <code>undefined</code> to leave <code>stdin</code> unchanged.
 * @property {stream.Writable=} stdout Stream which will act as
 * <code>stdout</code>.  <code>null</code> to discard output.
 * <code>undefined</code> to leave <code>stdout</code> unchanged.
 * @property {stream.Writable=} stderr Stream which will act as
 * <code>stderr</code>.  <code>null</code> to discard output.
 * <code>undefined</code> to leave <code>stderr</code> unchanged.
 * @property {boolean=} strict Throw an exception if {@see StdioContext#exit}
 * is improperly paired with {@see StdioContext#enter} or the streams have
 * been modified outside of StdioContext and <code>overwrite</code> is falsey.
 */
// var StdioContextOptions;

/** Creates a stdio context in which the given streams will be used as
 * <code>stdin</code>, <code>stdout</code>, and <code>stderr</code>.
 *
 * @constructor
 * @param {StdioContextOptions|Array<!stream.Readable|!stream.Writable>}
 * options Streams which will be used as <code>stdin</code>,
 * <code>stdout</code>, and <code>stderr</code> either as an object with named
 * properties or an <code>Array</code> of <code>[stdin, stdout, stderr]</code>.
 * @see StdioContextOptions
 */
function StdioContext(options) {  // eslint-disable-line consistent-return
  if (!(this instanceof StdioContext)) { return new StdioContext(options); }

  if (!options || typeof options !== 'object') {
    throw new TypeError('options must be an object');
  }

  var opts = {};
  Object.keys(options).forEach(function(prop) {
    // Accept child_process stdio argument type
    var destProp =
      prop === '0' ? 'stdin' :
      prop === '1' ? 'stdout' :
      prop === '2' ? 'stderr' :
      prop;
    opts[destProp] = options[prop];
  });
  this._options = opts;

  // Note:  .read() was not part of Readable in Streams v1
  if (opts.stdin && typeof opts.stdin.pipe !== 'function') {
    throw new TypeError('opts.stdin must be a stream.Readable');
  }
  if (opts.stdout && typeof opts.stdout.write !== 'function') {
    throw new TypeError('opts.stdout must be a stream.Writable');
  }
  if (opts.stderr && typeof opts.stderr.write !== 'function') {
    throw new TypeError('opts.stderr must be a stream.Writable');
  }

  // Name for debug logging
  instanceCount += 1;
  this._name = instanceCount.toString(16);
}

/** Enters this stdio context and starts using the stdio streams it defines.
 * @see StdioContext#exit
 */
StdioContext.prototype.enter = function enter() {
  var opts = this._options;

  debug('#enter() StdioContext %s', this._name, debugInspect(opts, {depth: 0}));

  var stdin = opts.stdin;
  if (stdin === null) {
    stdin = new EmptyStream();
  }

  var stdout = opts.stdout;
  if (stdout === null) {
    stdout = new DiscardStream();
  }

  var stderr = opts.stderr;
  if (stderr === null) {
    stderr = new DiscardStream();
  }

  // State of modified stdio streams when this method was called
  var beforeEnter = {
    stdin: stdin && replaceProperty(process, 'stdin', stdin),
    stdout: stdout && replaceProperty(process, 'stdout', stdout),
    stderr: stderr && replaceProperty(process, 'stderr', stderr)
  };

  // State of modified stdio streams when this method exits
  var afterEnter = {
    stdin: stdin && Object.getOwnPropertyDescriptor(process, 'stdin'),
    stdout: stdout && Object.getOwnPropertyDescriptor(process, 'stdout'),
    stderr: stderr && Object.getOwnPropertyDescriptor(process, 'stderr')
  };

  if (stdout || stderr) {
    var Console = console.Console;    // eslint-disable-line no-console
    var newConsole = new Console(process.stdout, process.stderr);
    // Constructor doesn't add the Console property.
    // Add it for consistent behavior and nested contexts
    newConsole.Console = Console;

    beforeEnter.console = replaceProperty(global, 'console', newConsole);
    afterEnter.console = Object.getOwnPropertyDescriptor(global, 'console');
  }

  contextStack.push({
    context: this,
    exited: false,
    beforeEnter: beforeEnter,
    afterEnter: afterEnter
  });
};

/** Exits this stdio context, stops using the stdio streams it defines, and
 * restores the stdio streams present when <code>enter()</code> was called.
 * @throws {Error} If this instance was constructed with
 * <code>options.strict</code> and the streams do not have the values set by
 * {@link StdioContext#enter}.
 * @see StdioContext#enter
 */
StdioContext.prototype.exit = function exit() {
  var self = this;
  var opts = this._options;

  debug('#exit() StdioContext %s', this._name, debugInspect(opts, {depth: 0}));

  function stateForThisContext(state) { return state.context === self; }
  var contextInd = contextStack.length - 1;
  while (contextInd >= 0 &&
      !stateForThisContext(contextStack[contextInd])) {
    contextInd -= 1;
  }
  if (contextInd < 0) {
    debug('#exit() called on a context which has already exited completely.');
    if (opts.strict) { throw new Error('Extra StdioContext#exit()'); }
    return;
  }

  if (contextInd !== contextStack.length - 1) {
    debug('#exit() called on a non-current context.');
    if (opts.strict) { throw new Error('Mismatched StdioContext#exit()'); }
    // TODO:  We could exit the context by restoring streams not overwritten
    // by later contexts.  This is subtle and requires modification of context
    // and preceding enter state.  Holding off until there is a real use case.
    contextStack[contextInd].exited = true;
    return;
  }

  contextStack[contextInd].exited = true;

  while (contextStack.length > 0 &&
      contextStack[contextStack.length - 1].exited) {
    restoreState(contextStack.pop());
  }
};

/**
 * Executes a function, which may be either synchronous or asynchronous, such
 * that it executes in this stdio context.
 *
 * This function behaves as if it called {@link #wrap} and immediately invoked
 * the result with the remaining arguments.
 *
 * @ template ReturnType
 * @param {function(...*): ReturnType} func Function to execute.
 * @param {...*} args Arguments passed to <code>func</code>.
 * @return {ReturnType} Value returned by <code>func</code>.
 * @see #wrap
 */
StdioContext.prototype.exec = function exec(func) {
  var wrapped = this.wrap(func);
  if (arguments.length === 1) {
    return wrapped();
  }

  var args = new Array(arguments.length - 1);
  for (var i = 1; i < arguments.length; i += 1) {
    args[i - 1] = arguments[i];
  }
  return wrapped.apply(null, args);
};

/**
 * Executes a synchronous function such that it executes in this stdio context.
 *
 * This function behaves as if it called {@link #wrap} and immediately invoked
 * the result with the remaining arguments.
 *
 *
 * @ template ReturnType
 * @param {function(...*): ReturnType} func Function to execute.
 * @param {...*} args Arguments passed to <code>func</code>.
 * @return {ReturnType} Value returned by <code>func</code>.
 * @see #wrapSync
 */
StdioContext.prototype.execSync = function execSync(func) {
  var wrapped = this.wrapSync(func);
  if (arguments.length === 1) {
    return wrapped();
  }

  var args = new Array(arguments.length - 1);
  for (var i = 1; i < arguments.length; i += 1) {
    args[i - 1] = arguments[i];
  }
  return wrapped.apply(null, args);
};

/**
 * Wraps a function, which may be either synchronous or asynchronous, such that
 * it executes in this stdio context.
 *
 * This stdio context is entered when the returned wrapper function is called
 * and exited under any of the following conditions:
 * - When the wrapped function throws an exception.
 * - When the wrapped function returns, if it didn't return a
 *   <code>Promise</code> and the last argument was not a function.
 * - When a <code>Promise</code> returned by the function is resolved or
 *   rejected.
 * - When a function passed as the last argument to the function is called.
 *
 * @ template Func
 * @param {Func} func Function to wrap.
 * @return {Func} A function which enters this stdio context, invokes
 * <code>func</code> with the arguments given and a wrapped callback, if
 * present.  Then exits this stdio context when the callback is first called,
 * or when the returned Promise is resolved or rejected, or immediately if the
 * invoked function throws an exception or doesn't have a Promise or callback.
 * @see #wrapSync
 */
StdioContext.prototype.wrap = function wrap(func) {
  // Since errors wouldn't be thrown until wrapped function is invoked, check em
  if (!(this instanceof StdioContext)) {
    throw new TypeError('wrap must be called on an StdioContext');
  }
  if (typeof func !== 'function') {
    throw new TypeError('func must be a function');
  }

  var stdioContext = this;
  return function stdioWrapped() {
    var exited = false;
    function exitStdioContext() {
      if (!exited) {
        exited = true;
        stdioContext.exit();
      }
    }

    var hasCallback = false;
    var callback = arguments[arguments.length - 1];
    if (typeof callback === 'function') {
      hasCallback = true;
      arguments[arguments.length - 1] = function stdioWrappedCallback() {
        exitStdioContext();
        return callback.apply(this, arguments);
      };
    }

    stdioContext.enter();

    var result;
    try {
      result = func.apply(this, arguments);
    } catch (err) {
      exitStdioContext();
      throw err;
    }

    if (result && typeof result.then === 'function') {
      // finally: https://github.com/domenic/promises-unwrapping/issues/18
      return result.then(
          function(value) { exitStdioContext(); return value; },
          function(reason) { exitStdioContext(); throw reason; }
      );
    }

    if (!hasCallback) {
      exitStdioContext();
    }

    return result;
  };
};

/**
 * Wraps a synchronous function such that it executes in this stdio context.
 *
 * This stdio context is entered when the returned wrapper function is called
 * and exited when the wrapped function returns or throws an exception.
 *
 * @ template Func
 * @param {Func} func Function to wrap.
 * @return {Func} A function which enters this stdio context, invokes
 * <code>func</code> with the arguments given, then exits this stdio context
 * upon return or exception from <code>func</code>.
 * @see #wrapSync
 */
StdioContext.prototype.wrapSync = function wrapSync(func) {
  // Since errors wouldn't be thrown until wrapped function is invoked, check em
  if (!(this instanceof StdioContext)) {
    throw new TypeError('wrapSync must be called on an StdioContext');
  }
  if (typeof func !== 'function') {
    throw new TypeError('func must be a function');
  }

  var stdioContext = this;
  return function stdioWrappedSync() {
    stdioContext.enter();
    try {
      return func.apply(this, arguments);
    } finally {
      stdioContext.exit();
    }
  };
};

module.exports = StdioContext;