From 93bc2b3bd7d9143410416e0f29993af69503143b Mon Sep 17 00:00:00 2001 From: Jakub Pawlowicz Date: Sun, 16 Nov 2014 12:33:21 +0000 Subject: [PATCH] Adds support for remote source maps. * http, https, and same protocol (//) are supported. * Options from `inliner` hash are used for timeouts / extra request options. * Errors & timeouts are handled gracefully. * Apparently nock.restore() should be nock.cleanAll(). --- lib/clean.js | 14 ++- lib/imports/inliner.js | 19 +++- lib/utils/input-source-map-tracker.js | 101 ++++++++++++++++--- test/protocol-imports-test.js | 36 +++---- test/source-map-test.js | 136 ++++++++++++++++++++++++++ 5 files changed, 263 insertions(+), 43 deletions(-) diff --git a/lib/clean.js b/lib/clean.js index be1a4f84..4374f574 100644 --- a/lib/clean.js +++ b/lib/clean.js @@ -78,10 +78,7 @@ CleanCSS.prototype.minify = function(data, callback) { }; function runMinifier(callback, self) { - return function (data) { - if (self.options.sourceMap) - self.inputSourceMapTracker = new InputSourceMapTracker(self.options).track(data); - + function whenSourceMapReady (data) { data = self.options.debug ? minifyWithDebug(self, data) : minify.call(self, data); @@ -89,6 +86,15 @@ function runMinifier(callback, self) { return callback ? callback.call(self, self.context.errors.length > 0 ? self.context.errors : null, data) : data; + } + + return function (data) { + if (self.options.sourceMap) { + self.inputSourceMapTracker = new InputSourceMapTracker(self.options, self.context); + return self.inputSourceMapTracker.track(data, function () { return whenSourceMapReady(data); }); + } else { + return whenSourceMapReady(data); + } }; } diff --git a/lib/imports/inliner.js b/lib/imports/inliner.js index c6541f2c..1d124a43 100644 --- a/lib/imports/inliner.js +++ b/lib/imports/inliner.js @@ -7,6 +7,9 @@ var url = require('url'); var UrlRewriter = require('../images/url-rewriter'); var Splitter = require('../utils/splitter.js'); +var MAP_MARKER = /\/\*# sourceMappingURL=(\S+) \*\//; +var REMOTE_RESOURCE = /^(https?:)?\/\//; + var merge = function(source1, source2) { var target = {}; for (var key1 in source1) @@ -17,6 +20,14 @@ var merge = function(source1, source2) { return target; }; +function rebaseMap(data, source) { + return data.replace(MAP_MARKER, function (match, sourceMapUrl) { + return REMOTE_RESOURCE.test(sourceMapUrl) ? + match : + match.replace(sourceMapUrl, url.resolve(source, sourceMapUrl)); + }); +} + function wrap(data, source) { return '__ESCAPED_SOURCE_CLEAN_CSS(' + source + ')__' + data + @@ -195,9 +206,7 @@ module.exports = function Inliner(context, options) { .replace(/^\)/, '') .trim(); - var isRemote = options.isRemote || - /^(http|https):\/\//.test(importedFile) || - /^\/\//.test(importedFile); + var isRemote = options.isRemote || REMOTE_RESOURCE.test(importedFile); if (options.localOnly && isRemote) { context.warnings.push('Ignoring remote @import declaration of "' + importedFile + '" as no callback given.'); @@ -211,7 +220,7 @@ module.exports = function Inliner(context, options) { }; var inlineRemoteResource = function(importedFile, mediaQuery, options) { - var importedUrl = /^https?:\/\//.test(importedFile) ? + var importedUrl = REMOTE_RESOURCE.test(importedFile) ? importedFile : url.resolve(options.relativeTo, importedFile); @@ -255,7 +264,7 @@ module.exports = function Inliner(context, options) { res.on('end', function() { var importedData = chunks.join(''); importedData = UrlRewriter.process(importedData, { toBase: importedUrl }); - importedData = wrap(importedData, url); + importedData = rebaseMap(wrap(importedData, importedUrl), importedUrl); if (mediaQuery.length > 0) importedData = '@media ' + mediaQuery + '{' + importedData + '}'; diff --git a/lib/utils/input-source-map-tracker.js b/lib/utils/input-source-map-tracker.js index faf52a4f..f2f200f1 100644 --- a/lib/utils/input-source-map-tracker.js +++ b/lib/utils/input-source-map-tracker.js @@ -2,31 +2,57 @@ 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 SOURCE_MARKER_START = /__ESCAPED_SOURCE_CLEAN_CSS\(([^~][^\)]+)\)__/; var SOURCE_MARKER_END = /__ESCAPED_SOURCE_END_CLEAN_CSS__/; var MAP_MARKER = /\/\*# sourceMappingURL=(\S+) \*\//; -function InputSourceMapStore(options) { +var DEFAULT_TIMEOUT = 5000; + + +function InputSourceMapStore(options, outerContext) { this.options = options; + this.errors = outerContext.errors; + this.timeout = (options.inliner && options.inliner.timeout) || DEFAULT_TIMEOUT; + this.requestOptions = (options.inliner && options.inliner.request) || {}; + this.maps = {}; } -InputSourceMapStore.prototype.track = function (data) { - if (typeof this.options.sourceMap == 'string') { - this.maps[undefined] = new SourceMapConsumer(this.options.sourceMap); - return this; +function merge(source1, source2) { + var target = {}; + for (var key1 in source1) + target[key1] = source1[key1]; + for (var key2 in source2) + target[key2] = source2[key2]; + + return target; +} + +function fromString(self, data, whenDone) { + self.maps[undefined] = new SourceMapConsumer(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); } - var files = []; - for (var cursor = 0, len = data.length; cursor < len; ) { - var fragment = data.substring(cursor, len); + while (context.cursor < data.length) { + var fragment = data.substring(context.cursor); var markerStartMatch = SOURCE_MARKER_START.exec(fragment) || { index: -1 }; var markerEndMatch = SOURCE_MARKER_END.exec(fragment) || { index: -1 }; var mapMatch = MAP_MARKER.exec(fragment) || { index: -1 }; - var nextAt = len; + nextAt = data.length; if (markerStartMatch.index > -1) nextAt = markerStartMatch.index; if (markerEndMatch.index > -1 && markerEndMatch.index < nextAt) @@ -34,22 +60,65 @@ InputSourceMapStore.prototype.track = function (data) { if (mapMatch.index > -1 && mapMatch.index < nextAt) nextAt = mapMatch.index; - if (nextAt == len) + if (nextAt == data.length) break; if (nextAt == markerStartMatch.index) { - files.push(markerStartMatch[1]); + context.files.push(markerStartMatch[1]); } else if (nextAt == markerEndMatch.index) { - files.pop(); + context.files.pop(); } else if (nextAt == mapMatch.index) { - var inputMapData = fs.readFileSync(path.join(this.options.root || '', mapMatch[1]), 'utf-8'); - this.maps[files[files.length - 1] || undefined] = new SourceMapConsumer(inputMapData); + var isRemote = /^https?:\/\//.test(mapMatch[1]) || /^\/\//.test(mapMatch[1]); + if (isRemote) { + return fetchMapFile(self, mapMatch[1], context, proceedToNext); + } else { + var inputMapData = fs.readFileSync(path.join(self.options.root || '', mapMatch[1]), 'utf-8'); + self.maps[context.files[context.files.length - 1] || undefined] = new SourceMapConsumer(inputMapData); + } } - cursor += nextAt + 1; + context.cursor += nextAt + 1; } - return this; + return whenDone(); +} + +function fetchMapFile(self, mapSource, context, done) { + function handleError(status) { + context.errors.push('Broken source map at "' + mapSource + '" - ' + status); + return done(); + } + + var method = mapSource.indexOf('https') === 0 ? https : http; + var requestOptions = merge(url.parse(mapSource), self.requestOptions); + + method + .get(requestOptions, function (res) { + if (res.statusCode < 200 || res.statusCode > 299) + return handleError(res.statusCode); + + var chunks = []; + res.on('data', function (chunk) { + chunks.push(chunk.toString()); + }); + res.on('end', function () { + self.maps[context.files[context.files.length - 1] || undefined] = new SourceMapConsumer(chunks.join('')); + done(); + }); + }) + .on('error', function(res) { + handleError(res.message); + }) + .on('timeout', function() { + handleError('timeout'); + }) + .setTimeout(self.timeout); +} + +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.isTracking = function () { diff --git a/test/protocol-imports-test.js b/test/protocol-imports-test.js index 32a2fe0b..ad25a53e 100644 --- a/test/protocol-imports-test.js +++ b/test/protocol-imports-test.js @@ -28,7 +28,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of an existing file': { @@ -47,7 +47,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of an existing file with spaces in path': { @@ -66,7 +66,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of an existing file via HTTPS': { @@ -85,7 +85,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of an existing file with media': { @@ -104,7 +104,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of an existing file with dependencies': { @@ -129,7 +129,7 @@ vows.describe('protocol imports').addBatch({ teardown: function() { assert.equal(this.reqMocks1.isDone(), true); assert.equal(this.reqMocks2.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of an existing file with relative dependencies': { @@ -150,7 +150,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of an existing file missing relative dependency': { @@ -172,7 +172,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of an existing file with URLs to rebase': { @@ -191,7 +191,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of an existing file with relative URLs to rebase': { @@ -212,7 +212,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of a non-resolvable domain': { @@ -245,7 +245,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of a 30x response with relative URL': { @@ -266,7 +266,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of a timed out response': { @@ -284,7 +284,7 @@ vows.describe('protocol imports').addBatch({ }).minify('@import url(http://localhost:' + port + '/timeout.css);a{color:red}', self.callback); }); }, - 'should not raise errors': function(errors, minified) { + 'should raise errors': function(errors, minified) { assert.equal(errors.length, 1); assert.equal(errors[0], 'Broken @import declaration of "http://localhost:' + port + '/timeout.css" - timeout'); }, @@ -313,7 +313,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of a resource without protocol': { @@ -332,7 +332,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of a resource available via POST only': { @@ -357,7 +357,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of a remote resource mixed with local ones': { @@ -377,7 +377,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), true); - nock.restore(); + nock.cleanAll(); } }, 'of a remote resource mixed with local ones but no callback': { @@ -403,7 +403,7 @@ vows.describe('protocol imports').addBatch({ }, teardown: function() { assert.equal(this.reqMocks.isDone(), false); - nock.restore(); + nock.cleanAll(); } } }).export(module); diff --git a/test/source-map-test.js b/test/source-map-test.js index 3c655e66..535f5bb9 100644 --- a/test/source-map-test.js +++ b/test/source-map-test.js @@ -1,3 +1,5 @@ +/* jshint unused: false */ + var vows = require('vows'); var assert = require('assert'); var CleanCSS = require('../index'); @@ -7,6 +9,11 @@ var path = require('path'); var inputMapPath = path.join('test', 'data', 'source-maps', 'styles.css.map'); var inputMap = fs.readFileSync(inputMapPath, 'utf-8'); +var nock = require('nock'); +var http = require('http'); + +var port = 24682; + vows.describe('source-map') .addBatch({ 'module #1': { @@ -335,4 +342,133 @@ vows.describe('source-map') } } }) + .addBatch({ + 'invalid response for external source map': { + topic: function () { + this.reqMocks = nock('http://127.0.0.1') + .get('/remote.css') + .reply(200, '/*# sourceMappingURL=http://127.0.0.1/remote.css.map */') + .get('/remote.css.map') + .reply(404); + + new CleanCSS({ sourceMap: true }).minify('@import url(http://127.0.0.1/remote.css);', this.callback); + }, + 'has mapping': function (errors, minified) { + assert.isDefined(minified.sourceMap); + }, + 'raises an error': function(errors, _) { + assert.equal(errors.length, 1); + assert.equal(errors[0], 'Broken source map at "http://127.0.0.1/remote.css.map" - 404'); + }, + teardown: function () { + assert.equal(this.reqMocks.isDone(), true); + nock.cleanAll(); + } + }, + 'timed out response for external source map': { + topic: function() { + var self = this; + var timeout = 100; + + this.server = http.createServer(function(req, res) { + switch (req.url) { + case '/remote.css': + res.writeHead(200); + res.write('/*# sourceMappingURL=http://127.0.0.1:' + port + '/remote.css.map */'); + res.end(); + break; + case '/remote.css.map': + setTimeout(function() {}, timeout * 2); + } + }); + this.server.listen(port, '127.0.0.1', function() { + new CleanCSS({ sourceMap: true, inliner: { timeout: timeout } }) + .minify('@import url(http://127.0.0.1:' + port + '/remote.css);', self.callback); + }); + }, + 'has mapping': function (errors, minified) { + assert.isDefined(minified.sourceMap); + }, + 'raises an error': function(errors, _) { + assert.equal(errors.length, 1); + assert.equal(errors[0], 'Broken source map at "http://127.0.0.1:' + port + '/remote.css.map" - timeout'); + }, + teardown: function () { + this.server.close(); + } + }, + 'absolute source map from external host via http': { + topic: function () { + this.reqMocks = nock('http://127.0.0.1') + .get('/remote.css') + .reply(200, '/*# sourceMappingURL=http://127.0.0.1/remote.css.map */') + .get('/remote.css.map') + .reply(200, inputMap); + + new CleanCSS({ sourceMap: true }).minify('@import url(http://127.0.0.1/remote.css);', this.callback); + }, + 'has mapping': function (errors, minified) { + assert.isDefined(minified.sourceMap); + }, + teardown: function () { + assert.equal(this.reqMocks.isDone(), true); + nock.cleanAll(); + } + }, + 'absolute source map from external host via https': { + topic: function () { + this.reqMocks = nock('https://127.0.0.1') + .get('/remote.css') + .reply(200, '/*# sourceMappingURL=https://127.0.0.1/remote.css.map */') + .get('/remote.css.map') + .reply(200, inputMap); + + new CleanCSS({ sourceMap: true }).minify('@import url(https://127.0.0.1/remote.css);', this.callback); + }, + 'has mapping': function (errors, minified) { + assert.isDefined(minified.sourceMap); + }, + teardown: function () { + assert.equal(this.reqMocks.isDone(), true); + nock.cleanAll(); + } + }, + 'relative source map from external host': { + topic: function () { + this.reqMocks = nock('http://127.0.0.1') + .get('/remote.css') + .reply(200, '/*# sourceMappingURL=remote.css.map */') + .get('/remote.css.map') + .reply(200, inputMap); + + new CleanCSS({ sourceMap: true }).minify('@import url(http://127.0.0.1/remote.css);', this.callback); + }, + 'has mapping': function (errors, minified) { + assert.isDefined(minified.sourceMap); + }, + teardown: function () { + assert.equal(this.reqMocks.isDone(), true); + nock.cleanAll(); + } + }, + 'available via POST only': { + topic: function () { + this.reqMocks = nock('http://127.0.0.1') + .post('/remote.css') + .reply(200, '/*# sourceMappingURL=remote.css.map */') + .post('/remote.css.map') + .reply(200, inputMap); + + new CleanCSS({ sourceMap: true, inliner: { request: { method: 'POST' } } }) + .minify('@import url(http://127.0.0.1/remote.css);', this.callback); + }, + 'has mapping': function (errors, minified) { + assert.isDefined(minified.sourceMap); + }, + teardown: function () { + assert.equal(this.reqMocks.isDone(), true); + nock.cleanAll(); + } + } + }) .export(module); -- 2.34.1