input-source-map-tracker.js 8.77 KB
var SourceMapConsumer = require('source-map').SourceMapConsumer;

var fs = require('fs');
var path = require('path');
var http = require('http');
var https = require('https');
var url = require('url');

var override = require('../utils/object.js').override;

var MAP_MARKER = /\/\*# sourceMappingURL=(\S+) \*\//;
var REMOTE_RESOURCE = /^(https?:)?\/\//;
var DATA_URI = /^data:(\S*?)?(;charset=[^;]+)?(;[^,]+?)?,(.+)/;

var unescape = global.unescape;

function InputSourceMapStore(outerContext) {
  this.options = outerContext.options;
  this.errors = outerContext.errors;
  this.warnings = outerContext.warnings;
  this.sourceTracker = outerContext.sourceTracker;
  this.timeout = this.options.inliner.timeout;
  this.requestOptions = this.options.inliner.request;
  this.localOnly = outerContext.localOnly;
  this.relativeTo = outerContext.options.target || process.cwd();

  this.maps = {};
  this.sourcesContent = {};
}

function fromString(self, _, whenDone) {
  self.trackLoaded(undefined, undefined, self.options.sourceMap);
  return whenDone();
}

function fromSource(self, data, whenDone, context) {
  var nextAt = 0;

  function proceedToNext() {
    context.cursor += nextAt + 1;
    fromSource(self, data, whenDone, context);
  }

  while (context.cursor < data.length) {
    var fragment = data.substring(context.cursor);

    var markerStartMatch = self.sourceTracker.nextStart(fragment) || { index: -1 };
    var markerEndMatch = self.sourceTracker.nextEnd(fragment) || { index: -1 };
    var mapMatch = MAP_MARKER.exec(fragment) || { index: -1 };
    var sourceMapFile = mapMatch[1];

    nextAt = data.length;
    if (markerStartMatch.index > -1)
      nextAt = markerStartMatch.index;
    if (markerEndMatch.index > -1 && markerEndMatch.index < nextAt)
      nextAt = markerEndMatch.index;
    if (mapMatch.index > -1 && mapMatch.index < nextAt)
      nextAt = mapMatch.index;

    if (nextAt == data.length)
      break;

    if (nextAt == markerStartMatch.index) {
      context.files.push(markerStartMatch.filename);
    } else if (nextAt == markerEndMatch.index) {
      context.files.pop();
    } else if (nextAt == mapMatch.index) {
      var isRemote = /^https?:\/\//.test(sourceMapFile) || /^\/\//.test(sourceMapFile);
      var isDataUri = DATA_URI.test(sourceMapFile);

      if (isRemote) {
        return fetchMapFile(self, sourceMapFile, context, proceedToNext);
      } else {
        var sourceFile = context.files[context.files.length - 1];
        var sourceMapPath, sourceMapData;
        var sourceDir = sourceFile ? path.dirname(sourceFile) : self.options.relativeTo;

        if (isDataUri) {
          // source map's path is the same as the source file it comes from
          sourceMapPath = path.resolve(self.options.root, sourceFile || '');
          sourceMapData = fromDataUri(sourceMapFile);
        } else {
          sourceMapPath = path.resolve(self.options.root, path.join(sourceDir || '', sourceMapFile));
          sourceMapData = fs.readFileSync(sourceMapPath, 'utf-8');
        }
        self.trackLoaded(sourceFile || undefined, sourceMapPath, sourceMapData);
      }
    }

    context.cursor += nextAt + 1;
  }

  return whenDone();
}

function fromDataUri(uriString) {
  var match = DATA_URI.exec(uriString);
  var charset = match[2] ? match[2].split(/[=;]/)[2] : 'us-ascii';
  var encoding = match[3] ? match[3].split(';')[1] : 'utf8';
  var data = encoding == 'utf8' ? unescape(match[4]) : match[4];

  var buffer = new Buffer(data, encoding);
  buffer.charset = charset;

  return buffer.toString();
}

function fetchMapFile(self, sourceUrl, context, done) {
  fetch(self, sourceUrl, function (data) {
    self.trackLoaded(context.files[context.files.length - 1] || undefined, sourceUrl, data);
    done();
  }, function (message) {
    context.errors.push('Broken source map at "' + sourceUrl + '" - ' + message);
    return done();
  });
}

function fetch(self, path, onSuccess, onFailure) {
  var protocol = path.indexOf('https') === 0 ? https : http;
  var requestOptions = override(url.parse(path), self.requestOptions);
  var errorHandled = false;

  protocol
    .get(requestOptions, function (res) {
      if (res.statusCode < 200 || res.statusCode > 299)
        return onFailure(res.statusCode);

      var chunks = [];
      res.on('data', function (chunk) {
        chunks.push(chunk.toString());
      });
      res.on('end', function () {
        onSuccess(chunks.join(''));
      });
    })
    .on('error', function (res) {
      if (errorHandled)
        return;

      onFailure(res.message);
      errorHandled = true;
    })
    .on('timeout', function () {
      if (errorHandled)
        return;

      onFailure('timeout');
      errorHandled = true;
    })
    .setTimeout(self.timeout);
}

function originalPositionIn(trackedSource, line, column, token, allowNFallbacks) {
  var originalPosition;
  var maxRange = token.length;
  var position = {
    line: line,
    column: column + maxRange
  };

  while (maxRange-- > 0) {
    position.column--;
    originalPosition = trackedSource.data.originalPositionFor(position);

    if (originalPosition)
      break;
  }

  if (originalPosition.line === null && line > 1 && allowNFallbacks > 0)
    return originalPositionIn(trackedSource, line - 1, column, token, allowNFallbacks - 1);

  if (trackedSource.path && originalPosition.source) {
    originalPosition.source = REMOTE_RESOURCE.test(trackedSource.path) ?
      url.resolve(trackedSource.path, originalPosition.source) :
      path.join(trackedSource.path, originalPosition.source);

    originalPosition.sourceResolved = true;
  }

  return originalPosition;
}

function trackContentSources(self, sourceFile) {
  var consumer = self.maps[sourceFile].data;
  var isRemote = REMOTE_RESOURCE.test(sourceFile);
  var sourcesMapping = {};

  consumer.sources.forEach(function (file, index) {
    var uniquePath = isRemote ?
      url.resolve(path.dirname(sourceFile), file) :
      path.relative(self.relativeTo, path.resolve(path.dirname(sourceFile || '.'), file));

    sourcesMapping[uniquePath] = consumer.sourcesContent && consumer.sourcesContent[index];
  });
  self.sourcesContent[sourceFile] = sourcesMapping;
}

function _resolveSources(self, remaining, whenDone) {
  function processNext() {
    return _resolveSources(self, remaining, whenDone);
  }

  if (remaining.length === 0)
    return whenDone();

  var current = remaining.shift();
  var sourceFile = current[0];
  var originalFile = current[1];
  var isRemote = REMOTE_RESOURCE.test(sourceFile);

  if (isRemote && self.localOnly) {
    self.warnings.push('No callback given to `#minify` method, cannot fetch a remote file from "' + originalFile + '"');
    return processNext();
  }

  if (isRemote) {
    fetch(self, originalFile, function (data) {
      self.sourcesContent[sourceFile][originalFile] = data;
      processNext();
    }, function (message) {
      self.warnings.push('Broken original source file at "' + originalFile + '" - ' + message);
      processNext();
    });
  } else {
    var fullPath = path.join(self.options.root, originalFile);
    if (fs.existsSync(fullPath))
      self.sourcesContent[sourceFile][originalFile] = fs.readFileSync(fullPath, 'utf-8');
    else
      self.warnings.push('Missing original source file at "' + fullPath + '".');
    return processNext();
  }
}

InputSourceMapStore.prototype.track = function (data, whenDone) {
  return typeof this.options.sourceMap == 'string' ?
    fromString(this, data, whenDone) :
    fromSource(this, data, whenDone, { files: [], cursor: 0, errors: this.errors });
};

InputSourceMapStore.prototype.trackLoaded = function (sourcePath, mapPath, mapData) {
  var relativeTo = this.options.explicitTarget ? this.options.target : this.options.root;
  var isRemote = REMOTE_RESOURCE.test(sourcePath);

  if (mapPath) {
    mapPath = isRemote ?
      path.dirname(mapPath) :
      path.dirname(path.relative(relativeTo, mapPath));
  }

  this.maps[sourcePath] = {
    path: mapPath,
    data: new SourceMapConsumer(mapData)
  };

  trackContentSources(this, sourcePath);
};

InputSourceMapStore.prototype.isTracking = function (source) {
  return !!this.maps[source];
};

InputSourceMapStore.prototype.originalPositionFor = function (sourceInfo, token, allowNFallbacks) {
  return originalPositionIn(this.maps[sourceInfo.source], sourceInfo.line, sourceInfo.column, token, allowNFallbacks);
};

InputSourceMapStore.prototype.sourcesContentFor = function (contextSource) {
  return this.sourcesContent[contextSource];
};

InputSourceMapStore.prototype.resolveSources = function (whenDone) {
  var toResolve = [];

  for (var sourceFile in this.sourcesContent) {
    var contents = this.sourcesContent[sourceFile];
    for (var originalFile in contents) {
      if (!contents[originalFile])
        toResolve.push([sourceFile, originalFile]);
    }
  }

  return _resolveSources(this, toResolve, whenDone);
};

module.exports = InputSourceMapStore;