mincer
Version:
Web assets processor. Native JavaScript port of Sprockets.
483 lines (398 loc) • 12.8 kB
JavaScript
/**
* class Server
*
* Easy to use server/middleware ideal for serving assets your assets:
*
* - great for development, as it recompiles canged assets on-fly
* - great for production, as it caches results, and it can become as effecient
* as `staticCache` middleware (or even better) of `connect` module.
*
*
* ##### Examples
*
* // development mode
* var srv = new Server(env);
*
* // production mode
* var srv = new Server(env.index);
*
* // production mode (restrictive)
* var files = ['app.js', 'app.css', 'logo.jpg'];
* var srv = new Server(env.index, manifest.compile(files));
*
* You can use this server in your connect app (or as `request` listener of
* `http` server) like this:
*
* app.use(function (req, res) {
* srv.handle(req, res);
* });
*
* // there's a shorthand syntax as well:
*
* app.use(mincer.createServer(env));
**/
;
// stdlib
var zlib = require('zlib');
var http = require('http');
var url = require('url');
var format = require('util').format;
// 3rd-party
var mimoza = require('mimoza');
var compressible = require('compressible');
// internal
var logger = require('./logger');
var prop = require('./common').prop;
var start_timer = require('./common').timer;
////////////////////////////////////////////////////////////////////////////////
/**
* new Server(environment[, manifest])
* - environment (Environment|Index)
* - manifest (Object): Data returned by [[Manifest#compile]]
*
* If you provide `manifest`, then server will not even try to find files on
* FS unless they are specified in the `manifest`.
**/
var Server = module.exports = function Server(environment, manifest) {
prop(this, 'environment', environment);
prop(this, 'manifest', manifest);
};
// Retruns fingerprint from the pathname
var FINGERPRINT_RE = /-([0-9a-f]{32,40})\.[^.]+(?:\.map)?$/i;
function get_fingerprint(pathname) {
var m = FINGERPRINT_RE.exec(pathname);
return m ? m[1] : null;
}
// Helper to write the code and end response
function end(res, code) {
if (code >= 400) {
// check res object contains connect/express request and next structure
if (res.req && res.req.next) {
var error = new Error(http.STATUS_CODES[code]);
error.status = code;
return res.req.next(error);
}
// write human-friendly error message
res.writeHead(code);
res.end('[' + code + '] ' + http.STATUS_CODES[code]);
return;
}
// just end with no body for 304 responses and such
res.writeHead(code);
res.end();
}
// Returns Etag value for `asset`
function etag(asset) {
return '"' + asset.digest + '"';
}
// Returns true whenever If-None-Match header matches etag of `asset`
function is_etag_match(req, asset) {
return etag(asset) === req.headers['if-none-match'];
}
// Tells whenever browser accepts gzip at all
function is_gzip_accepted(req) {
var accept = req.headers['accept-encoding'] || '';
return accept === '*' || accept.indexOf('gzip') >= 0;
}
// Returns log event structure.
//
function log_event(req, code, message, elapsed) {
return {
code: code,
message: message,
elapsed: elapsed,
request: req,
url: req.originalUrl || req.url,
method: req.method,
headers: req.headers,
httpVersion: req.httpVersion,
remoteAddress: req.connection.remoteAddress
};
}
function serve_asset(self, asset, req, res, timer) {
var buffer, length;
// OK
self.log('info', log_event(req, 200, 'OK', timer.stop()));
buffer = asset.__server_buffer__;
length = asset.__server_buffer__.length;
//
// Alter some headers for gzipped assets
//
//
// Only gzip is supported:
//
// - too many issues with deflate
// - browsers that support deflate well, also support gzip
//
// Details:
//
// - http://www.vervestudios.co/projects/compression-tests/results
// - http://zoompf.com/blog/2012/02/lose-the-wait-http-compression
//
if (asset.__server_buffer_gzipped__ && is_gzip_accepted(req)) {
buffer = asset.__server_buffer_gzipped__;
length = asset.__server_buffer_gzipped__.length;
res.setHeader('Content-Encoding', 'gzip');
}
//
// Set content type and length headers
// Force charset for text assets, to avoid problems with JS loaders
//
res.setHeader('Content-Type', asset.contentType + (mimoza.isText(asset.contentType) ? '; charset=UTF-8' : ''));
res.setHeader('Content-Length', length);
res.statusCode = 200;
if (req.method === 'HEAD') {
res.end();
return;
}
res.end(buffer);
}
function serve_source_map(self, asset, req, res, timer) {
var length, buffer;
if (!asset.__server_sourcemap_buffer__) {
self.log('info', log_event(req, 404, 'Not Found', timer.stop()));
res.statusCode = 404;
} else {
self.log('info', log_event(req, 200, 'OK', timer.stop()));
res.statusCode = 200;
buffer = asset.__server_sourcemap_buffer__;
length = asset.__server_sourcemap_buffer__.length;
if (asset.__gzipped_server_sourcemap__ && is_gzip_accepted(req)) {
buffer = asset.__gzipped_server_sourcemap__;
length = asset.__gzipped_server_sourcemap__.length;
res.setHeader('Content-Encoding', 'gzip');
}
res.setHeader('Content-Type', 'application/json; charset=UTF-8');
res.setHeader('Content-Length', length);
}
if (req.method === 'HEAD') {
res.end();
return;
}
res.end(buffer);
}
/** internal
* Server#log(level, event) -> Void
* - level (String): Event level
* - event (Object): Event data
*
* This is an internal method that formats and writes messages using
* [[Mincer.logger]] and it fits almost 99% of cases. But if you want to
* integrate this [[Server]] into your existing application and have logs
* formatted in your way you can override this method.
*
*
* ##### Event
*
* Event is an object with following fields:
*
* - **code** (Number): Status code
* - **message** (String): Message
* - **elapsed** (Number): Time elapsed in milliseconds
* - **url** (String): Request url. See `http.request.url`.
* - **method** (String): Request method. See `http.request.method`.
* - **headers** (Object): Request headers. See `http.request.headers`.
* - **httpVersion** (String): Request httpVersion. See `http.request.httpVersion`.
**/
Server.prototype.log = function log(level, event) {
logger[level](format('Served asset %s - %d %s (%dms)',
event.url, event.code, event.message, event.elapsed));
};
/**
* Server#compile(pathname, bundle, callback(err, asset)) -> Void
* - pathname (String)
* - bundle (Boolean)
* - callback (Function)
*
* Finds and compiles given asset.
**/
Server.prototype.compile = function compile(pathname, bundle, callback) {
var asset;
try {
asset = (this.manifest && !this.manifest.assets[pathname]) ?
null : this.environment.findAsset(pathname, { bundle: !!bundle });
} catch (err) {
callback(err);
return;
}
if (!asset) {
callback(/* err = undefined, asset = undefined */);
return;
}
// return immediately if asset was previously processed
if (asset.__server_buffer__) {
callback(null, asset);
return;
}
// For bundled assets create patched content with embedded url.
if (asset.sourceMap && asset.mappingUrlComment) {
prop(asset, '__server_buffer__', new Buffer(asset.source + asset.mappingUrlComment()));
} else {
prop(asset, '__server_buffer__', asset.buffer);
}
if (asset.sourceMap) {
prop(asset, '__server_sourcemap_buffer__', new Buffer(')]}\'\n' + asset.sourceMap));
// Strange ")]}'\n" line added to sourcemap is for XSSI protection.
// See spec for details.
}
if (!compressible(asset.contentType)) {
callback(null, asset);
return;
}
// Gzip and cache buffer
zlib.gzip(asset.__server_buffer__, function (err, buffer) {
if (err) {
callback(err);
return;
}
// set __server_buffer_gzipped__ buffer only if we have compression profit
if (buffer.length < asset.__server_buffer__.length) {
prop(asset, '__server_buffer_gzipped__', buffer);
}
if (!asset.__server_sourcemap_buffer__) {
callback(null, asset);
return;
}
zlib.gzip(asset.__server_sourcemap_buffer__, function (err, buffer) {
if (err) {
callback(err);
return;
}
// set __server_buffer_gzipped__ buffer only if we have compression profit
if (buffer.length < asset.__server_sourcemap_buffer__.length) {
prop(asset, '__gzipped_server_sourcemap__', buffer);
}
callback(null, asset);
});
});
};
/**
* Server#handle(req, res) -> Void
* - req (http.ServerRequest)
* - res (hhtp.ServerResponse)
*
* Hander function suitable for usage as server `request` event listener or as
* middleware for TJ's `connect` module.
*
*
* ##### Exampple
*
* var assetsSet
**/
Server.prototype.handle = function handle(req, res) {
var self = this,
timer = start_timer(),
pathname = url.parse(req.url).pathname,
bundle = !/body=[1t]/.test(url.parse(req.url).query),
sourceMap = /\.map$/i.test(pathname),
fingerprint = get_fingerprint(pathname);
try {
pathname = decodeURIComponent(pathname.replace(/^\//, ''));
} catch (err) {
self.log('error', log_event(req, 400, 'Failed decode URL', timer.stop()));
end(res, 400);
return;
}
// forbid requests with `..` or NUL chars
if (pathname.indexOf('..') >= 0 || pathname.indexOf('\u0000') >= 0) {
self.log('error', log_event(req, 403, 'URL contains unsafe chars', timer.stop()));
end(res, 403);
return;
}
// ignore non-GET requests
if (req.method !== 'GET' && req.method !== 'HEAD') {
self.log('error', log_event(req, 403, 'HTTP method not allowed', timer.stop()));
end(res, 403);
return;
}
// remove fingerprint (digest) from URL
if (fingerprint) {
pathname = pathname.replace('-' + fingerprint, '');
}
// remove .map extension from pathname
if (sourceMap) {
pathname = pathname.replace(/\.map$/i, '');
}
// try to find and compile asset
this.compile(pathname, bundle, function (err, asset) {
if (err) {
err = err.message || err.toString();
self.log('error', log_event(req, 500, 'Error compiling asset: ' + err, timer.stop()));
end(res, 500);
return;
}
// asset not found
if (!asset) {
self.log('error', log_event(req, 404, 'Not found', timer.stop()));
end(res, 404);
return;
}
//
// Asset found. Sending headers for 200/304 responses:
// http://tools.ietf.org/html/draft-ietf-httpbis-p4-conditional-18#section-4.1
//
//
// Ranges are not supported yet
//
res.removeHeader('Accept-Ranges');
//
// Mark for proxies, that we can return different content (plain & gzipped),
// depending on specified (comma-separated) headers
//
res.setHeader('Vary', 'Accept-Encoding');
//
// Set caching headers
//
if (fingerprint) {
// If the request url contains a fingerprint, set a long
// expires on the response
res.setHeader('Cache-Control', 'public, max-age=31536000');
} else {
// Otherwise set `must-revalidate` since the asset could be modified.
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
}
res.setHeader('Date', (new Date()).toUTCString());
res.setHeader('Last-Modified', asset.mtime.toUTCString());
res.setHeader('ETag', etag(asset));
res.setHeader('Server', 'Nokla 1630');
//
// Check if asset's etag matches `if-none-match` header
//
if (is_etag_match(req, asset)) {
self.log('info', log_event(req, 304, 'Not Modified', timer.stop()));
end(res, 304);
return;
}
if (sourceMap) {
serve_source_map(self, asset, req, res, timer);
} else {
serve_asset(self, asset, req, res, timer);
}
});
};
/**
* Server.createServer(environment[, manifest]) -> Function
* - environment (Environment)
* - manifest (Object)
*
* Returns a server function suitable to be used as `request` event handler of
* `http` Node library module or as `connect` middleware.
*
*
* ##### Example
*
* // Using TJ's Connect module
* var app = connect();
* app.use('/assets/', Server.createServer(env));
*
*
* ##### See Also
*
* - [[Server.new]]
**/
Server.createServer = function (environment, manifest) {
var srv = new Server(environment, manifest);
return function (req, res) {
return srv.handle(req, res);
};
};