var fs = require('fs-extra'); var srcURL = require('source-map-url'); var path = require('path'); var RSVP = require('rsvp'); var MemoryStreams = require('memory-streams'); var Coder = require('./coder'); var crypto = require('crypto'); var chalk = require('chalk'); var EOL = require('os').EOL; var validator = require('sourcemap-validator'); var logger = require('heimdalljs-logger')('fast-sourcemap-concat:'); module.exports = SourceMap; function SourceMap(opts) { if (!(this instanceof SourceMap)) { return new SourceMap(opts); } if (!opts || (!opts.outputFile && (!opts.mapURL || !opts.file))) { throw new Error("Must specify at least outputFile or mapURL and file"); } if (opts.mapFile && !opts.mapURL) { throw new Error("must specify the mapURL when setting a custom mapFile"); } this.baseDir = opts.baseDir; this.outputFile = opts.outputFile; this.cache = opts.cache; this.mapFile = opts.mapFile || (this.outputFile && this.outputFile.replace(/\.js$/, '') + '.map'); this.mapURL = opts.mapURL || (this.mapFile && path.basename(this.mapFile)); this.mapCommentType = opts.mapCommentType || 'line'; this._initializeStream(); this.id = opts.pluginId; this.content = { version: 3, sources: [], sourcesContent: [], names: [], mappings: '' }; if (opts.sourceRoot) { this.content.sourceRoot = opts.sourceRoot; } this.content.file = opts.file || path.basename(opts.outputFile); this.encoder = new Coder(); // Keep track of what column we're currently outputing in the // concatenated sourcecode file. Notice that we don't track line // though -- line is implicit in this.content.mappings. this.column = 0; // Keep track of how many lines worth of mappings we've output into // the concatenated sourcemap. We use this to correct broken input // sourcemaps that don't match the length of their sourcecode. this.linesMapped = 0; this._sizes = {}; } SourceMap.prototype._resolveFile = function(filename) { if (this.baseDir && filename.slice(0,1) !== '/') { filename = path.join(this.baseDir, filename); } return filename; }; SourceMap.prototype._initializeStream = function() { if (this.outputFile) { fs.mkdirpSync(path.dirname(this.outputFile)); this.stream = fs.createWriteStream(this.outputFile); } else { this.stream = new MemoryStreams.WritableStream(); } }; SourceMap.prototype.addFile = function(filename) { var source = ensurePosix(fs.readFileSync(this._resolveFile(filename), 'utf-8')); this._sizes[filename] = source.length; return this.addFileSource(filename, source); }; SourceMap.prototype.addFileSource = function(filename, source, inputSrcMap) { var url; if (source.length === 0) { return; } if (srcURL.existsIn(source)) { url = srcURL.getFrom(source); source = srcURL.removeFrom(source); } if (this.content.mappings.length > 0 && !/[;,]$/.test(this.content.mappings)) { this.content.mappings += ','; } if (typeof inputSrcMap === 'string') { inputSrcMap = JSON.parse(inputSrcMap); } if (inputSrcMap === undefined && url) { inputSrcMap = this._resolveSourcemap(filename, url); } var valid = true; if (inputSrcMap) { try { // TODO: don't stringify here validator(source, JSON.stringify(inputSrcMap)); } catch (e) { logger.error(' invalid sourcemap for: %s', filename); if (typeof e === 'object' && e !== null) { logger.error(' error: ', e.message); } // print valid = false; } } if (inputSrcMap && valid) { var haveLines = countNewLines(source); source = this._addMap(filename, inputSrcMap, source, haveLines); } else { logger.info('generating new map: %s', filename); this.content.sources.push(filename); this.content.sourcesContent.push(source); this._generateNewMap(source); } this.stream.write(source); }; SourceMap.prototype._cacheEncoderResults = function(key, operations, filename) { var encoderState = this.encoder.copy(); var initialLinesMapped = this.linesMapped; var cacheEntry = this.cache[key]; var finalState; if (cacheEntry) { // The cache contains the encoder-differential for our file. So // this updates encoderState to the final value our encoder will // have after processing the file. encoderState.decode(cacheEntry.encoder); // We provide that final value as a performance hint. operations.call(this, { encoder: encoderState, lines: cacheEntry.lines }); logger.info('encoder cache hit: %s', filename); } else { // Run the operations with no hint because we don't have one yet. operations.call(this); // Then store the encoder differential in the cache. finalState = this.encoder.copy(); finalState.subtract(encoderState); this.cache[key] = { encoder: finalState.serialize(), lines: this.linesMapped - initialLinesMapped }; logger.info('encoder cache prime: %s', filename); } }; // This is useful for things like separators that you're appending to // your JS file that don't need to have their own source mapping, but // will alter the line numbering for subsequent files. SourceMap.prototype.addSpace = function(source) { this.stream.write(source); var lineCount = countNewLines(source); if (lineCount === 0) { this.column += source.length; } else { this.column = 0; var mappings = this.content.mappings; for (var i = 0; i < lineCount; i++) { mappings += ';'; } this.content.mappings = mappings; } }; SourceMap.prototype._generateNewMap = function(source) { var mappings = this.content.mappings; var lineCount = countNewLines(source); mappings += this.encoder.encode({ generatedColumn: this.column, source: this.content.sources.length-1, originalLine: 0, originalColumn: 0 }); if (lineCount === 0) { // no newline in the source. Keep outputting one big line. this.column += source.length; } else { // end the line this.column = 0; this.encoder.resetColumn(); mappings += ';'; this.encoder.adjustLine(lineCount-1); } // For the remainder of the lines (if any), we're just following // one-to-one. for (var i = 0; i < lineCount-1; i++) { mappings += 'AACA;'; } this.linesMapped += lineCount; this.content.mappings = mappings; }; SourceMap.prototype._resolveSourcemap = function(filename, url) { var srcMap; var match = /^data:.+?;base64,/.exec(url); try { if (match) { srcMap = new Buffer(url.slice(match[0].length), 'base64'); } else if (this.baseDir && url.slice(0,1) === '/') { srcMap = fs.readFileSync( path.join(this.baseDir, url), 'utf8' ); } else { srcMap = fs.readFileSync( path.join(path.dirname(this._resolveFile(filename)), url), 'utf8' ); } return JSON.parse(srcMap); } catch (err) { this._warn('Warning: ignoring input sourcemap for ' + filename + ' because ' + err.message); } }; SourceMap.prototype._addMap = function(filename, srcMap, source) { var initialLinesMapped = this.linesMapped; var haveLines = countNewLines(source); var self = this; if (this.cache) { this._cacheEncoderResults(hash(JSON.stringify(srcMap)), function(cacheHint) { self._assimilateExistingMap(filename, srcMap, cacheHint); }, filename); } else { this._assimilateExistingMap(filename, srcMap); } while (this.linesMapped - initialLinesMapped < haveLines) { // This cleans up after upstream sourcemaps that are too short // for their sourcecode so they don't break the rest of our // mapping. Coffeescript does this. this.content.mappings += ';'; this.linesMapped++; } while (haveLines < this.linesMapped - initialLinesMapped) { // Likewise, this cleans up after upstream sourcemaps that are // too long for their sourcecode. source += "\n"; haveLines++; } return source; }; SourceMap.prototype._assimilateExistingMap = function(filename, srcMap, cacheHint) { var content = this.content; var sourcesOffset = content.sources.length; var namesOffset = content.names.length; content.sources = content.sources.concat(this._resolveSources(srcMap)); content.sourcesContent = content.sourcesContent.concat(this._resolveSourcesContent(srcMap, filename)); while (content.sourcesContent.length > content.sources.length) { content.sourcesContent.pop(); } while (content.sourcesContent.length < content.sources.length) { content.sourcesContent.push(null); } content.names = content.names.concat(srcMap.names); this._scanMappings(srcMap, sourcesOffset, namesOffset, cacheHint); }; SourceMap.prototype._resolveSources = function(srcMap) { var baseDir = this.baseDir; if (!baseDir) { return srcMap.sources; } return srcMap.sources.map(function(src) { return src.replace(baseDir, ''); }); }; SourceMap.prototype._resolveSourcesContent = function(srcMap, filename) { if (srcMap.sourcesContent) { // Upstream srcmap already had inline content, so easy. return srcMap.sourcesContent; } else { // Look for original sources relative to our input source filename. return srcMap.sources.map(function(source){ var fullPath; if (path.isAbsolute(source)) { fullPath = source; } else { fullPath = path.join(path.dirname(this._resolveFile(filename)), source); } return ensurePosix(fs.readFileSync(fullPath, 'utf-8')); }.bind(this)); } }; SourceMap.prototype._scanMappings = function(srcMap, sourcesOffset, namesOffset, cacheHint) { var mappings = this.content.mappings; var decoder = new Coder(); var inputMappings = srcMap.mappings; var pattern = /^([;,]*)([^;,]*)/; var continuation = /^([;,]*)((?:AACA;)+)/; var initialMappedLines = this.linesMapped; var match; var lines; while (inputMappings.length > 0) { match = pattern.exec(inputMappings); // If the entry was preceded by separators, copy them through. if (match[1]) { mappings += match[1]; lines = match[1].replace(/,/g, '').length; if (lines > 0) { this.linesMapped += lines; this.encoder.resetColumn(); decoder.resetColumn(); } } // Re-encode the entry. if (match[2]){ var value = decoder.decode(match[2]); value.generatedColumn += this.column; this.column = 0; if (sourcesOffset && value.hasOwnProperty('source')) { value.source += sourcesOffset; decoder.prev_source += sourcesOffset; sourcesOffset = 0; } if (namesOffset && value.hasOwnProperty('name')) { value.name += namesOffset; decoder.prev_name += namesOffset; namesOffset = 0; } mappings += this.encoder.encode(value); } inputMappings = inputMappings.slice(match[0].length); // Once we've applied any offsets, we can try to jump ahead. if (!sourcesOffset && !namesOffset) { if (cacheHint) { // Our cacheHint tells us what our final encoder state will be // after processing this file. And since we've got nothing // left ahead that needs rewriting, we can just copy the // remaining mappings over and jump to the final encoder // state. mappings += inputMappings; inputMappings = ''; this.linesMapped = initialMappedLines + cacheHint.lines; this.encoder = cacheHint.encoder; } if ((match = continuation.exec(inputMappings))) { // This is a significant optimization, especially when we're // doing simple line-for-line concatenations. lines = match[2].length / 5; this.encoder.adjustLine(lines); this.encoder.resetColumn(); decoder.adjustLine(lines); decoder.resetColumn(); this.linesMapped += lines + match[1].replace(/,/g, '').length; mappings += match[0]; inputMappings = inputMappings.slice(match[0].length); } } } // ensure we always reset the column. This is to ensure we remain resilient // to invalid input. this.encoder.resetColumn(); this.content.mappings = mappings; }; SourceMap.prototype.writeConcatStatsSync = function(outputPath, content) { fs.mkdirpSync(path.dirname(outputPath)); fs.writeFileSync(outputPath, JSON.stringify(content, null, 2)); }; SourceMap.prototype.end = function(cb, thisArg) { var stream = this.stream; var sourceMap = this; return new RSVP.Promise(function(resolve, reject) { stream.on('error', function(error) { stream.on('close', function() { reject(error); }); stream.end(); }); var error, success; try { if (cb) { cb.call(thisArg, sourceMap); } if (process.env.CONCAT_STATS) { var outputPath = process.cwd() + '/concat-stats-for/' + sourceMap.id + '-' + path.basename(sourceMap.outputFile) + '.json'; sourceMap.writeConcatStatsSync( outputPath, { outputFile: sourceMap.outputFile, sizes: sourceMap._sizes } ); } if (sourceMap.mapCommentType === 'line') { stream.write('//# sourceMappingURL=' + sourceMap.mapURL + '\n'); } else { stream.write('/*# sourceMappingURL=' + sourceMap.mapURL + ' */\n'); } if (sourceMap.mapFile) { fs.mkdirpSync(path.dirname(sourceMap.mapFile)); fs.writeFileSync(sourceMap.mapFile, JSON.stringify(sourceMap.content)); } success = true; } catch(e) { success = false; error = e; } finally { if (sourceMap.outputFile) { stream.on('close', function() { if (success) { resolve(); } else { reject(error); } }); stream.end(); } else { resolve({ code: stream.toString(), map: sourceMap.content }); } } }); }; SourceMap.prototype._warn = function(msg) { console.warn(chalk.yellow(msg)); }; function countNewLines(src) { var newlinePattern = /(\r?\n)/g; var count = 0; while (newlinePattern.exec(src)) { count++; } return count; } function hash(string) { return crypto.createHash('md5').update(string).digest('hex'); } function ensurePosix(string) { if (EOL !== '\n') { string = string.split(EOL).join('\n'); } return string; }