UNPKG

mincer

Version:

Web assets processor. Native JavaScript port of Sprockets.

483 lines (398 loc) 12.8 kB
/** * 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)); **/ 'use strict'; // 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); }; };