express-negotiator
Version:
Express/connect middleware that does HTTP content negotiation for static files
413 lines (384 loc) • 22 kB
JavaScript
var fs = require('fs'),
Path = require('path'),
_ = require('underscore'),
async = require('async'),
passError = require('passerror'),
memoizeAsync = require('memoizeasync'),
memoizeSync = require('memoizesync'),
mime = require('mime'),
mario = require('mario'),
// List taken from an older version of CLDR, so it's probably incomplete:
isLocaleIdRegExp = /^(?:aa|af|ak|am|ar|as|asa|az|be|bem|bez|bg|bm|bn|bo|br|brx|bs|byn|ca|cch|cgg|chr|cs|cy|da|dav|de|dv|dz|ebu|ee|el|en|eo|es|et|eu|fa|ff|fi|fil|fo|fr|fur|ga|gaa|gez|gl|gsw|gu|guz|gv|ha|haw|he|hi|hr|hu|hy|ia|id|ig|ii|in|is|it|iu|iw|ja|jmc|ka|kab|kaj|kam|kcg|kde|kea|kfo|khq|ki|kk|kl|kln|km|kn|ko|kok|kpe|ksb|ksh|ku|kw|ky|lag|lg|ln|lo|lt|luo|luy|lv|mas|mer|mfe|mg|mi|mk|ml|mn|mo|mr|ms|mt|my|naq|nb|nd|nds|ne|nl|nn|no|nr|nso|ny|nyn|oc|om|or|pa|pl|ps|pt|rm|ro|rof|root|ru|rw|rwk|sa|saq|se|seh|ses|sg|sh|shi|si|sid|sk|sl|sn|so|sq|sr|ss|ssy|st|sv|sw|syr|ta|te|teo|tg|th|ti|tig|tl|tn|to|tr|trv|ts|tt|tzm|ug|uk|ur|uz|ve|vi|vun|wal|wo|xh|xog|yo|zh|zu)(?:_|$)/,
aliasesByLocaleId = {
no: ['nb'],
nb: ['no']
};
function localeIdsEqual(localeId1, localeId2) {
return (
localeId1 === localeId2 ||
(aliasesByLocaleId[localeId1] && aliasesByLocaleId[localeId1].indexOf(localeId2) !== -1) ||
(aliasesByLocaleId[localeId2] && aliasesByLocaleId[localeId2].indexOf(localeId1) !== -1)
);
}
// Note: These obscure extensions are listed in require('mime').types AND are considered valid locale ids: nb, so, ps, kfo, rm, st, sh, sid, in, tr, ms, gv, ts
// When resolving the variants we interpret them as locale ids.
// From Express 3.0:
function parseQualityToken(str) {
var parts = str.split(/ *; */),
val = parts[0],
q = parts[1] ? parseFloat(parts[1].split(/ *= */)[1]) : 1;
return {value: val, quality: q};
}
function parseQuality(str) {
return str
.split(/ *, */)
.map(parseQualityToken)
.filter(function (obj) {
return obj.quality;
})
.sort(function (a, b) {
return b.quality - a.quality;
});
}
/*
* Replace - with _ and convert to lower case: en-GB => en_gb
*/
function normalizeLocaleId(localeId) {
return localeId.replace(/-/g, '_').toLowerCase();
}
/*
* Helper for getting a prioritized list of relevant locale ids from a
* specific locale id. For instance, `en_US` produces `["en_US", "en"]`.
*/
function expandLocaleIdToPrioritizedList(localeId) {
localeId = normalizeLocaleId(localeId);
var localeIds = [localeId];
while (/_[^_]+$/.test(localeId)) {
localeId = localeId.replace(/_[^_]+$/, '');
localeIds.push(localeId);
}
return localeIds;
}
var userAgentTypes = ['touch', 'ie', 'chrome', 'phantom', 'safari', 'ios', 'iphone', 'ipad', 'touchpad', 'android', 'opera', 'firefox', 'seamonkey'],
isValidByUserAgentType = {};
// Add nonmobile, nonie...:
Array.prototype.push.apply(userAgentTypes, userAgentTypes.map(function (userAgentType) {return 'non' + userAgentType;}));
userAgentTypes.forEach(function (userAgentType) {
isValidByUserAgentType[userAgentType] = true;
});
// .html.en-US => {contentType: 'text/html', contentTypeFragments: ['text', 'html'], localeId: 'en_us'}
// .html.mobile.en-US => {contentType: 'text/html', contentTypeFragments: ['text', 'html'], userAgent: {mobile: true}, localeId: 'en_us'}
function getVariantInfoFromExtensionString(extensionString, supportUserAgent) {
var variantInfo = {
allExtensionsMatched: true,
unmatchedExtensionsWithoutLeadingDot: []
};
if (supportUserAgent) {
variantInfo.userAgent = {};
}
if (extensionString) {
variantInfo.extensionsWithoutLeadingDot = extensionString.split(/(?=\.)/).map(function (extensionWithLeadingDot) {
return extensionWithLeadingDot.substr(1);
});
variantInfo.extensionsWithoutLeadingDot.forEach(function (extensionWithoutLeadingDot) {
var localeId = normalizeLocaleId(extensionWithoutLeadingDot);
if (isLocaleIdRegExp.test(localeId)) {
variantInfo.localeId = localeId;
} else {
var contentType = mime.types[extensionWithoutLeadingDot];
if (contentType) {
variantInfo.contentType = contentType;
variantInfo.contentTypeFragments = contentType.split('/');
} else if (supportUserAgent && isValidByUserAgentType[extensionWithoutLeadingDot]) {
variantInfo.userAgent[extensionWithoutLeadingDot] = true;
} else {
variantInfo.allExtensionsMatched = false;
variantInfo.unmatchedExtensionsWithoutLeadingDot.push(extensionWithoutLeadingDot);
}
}
});
}
return variantInfo;
}
module.exports = function (options) {
options = options || {};
if (!('root' in options)) {
throw new Error('negotiator: options.root is required');
}
var roots = Array.isArray(options.root) ? options.root : [options.root],
fsWatcherByDirName = {},
getVariantInfosByBaseName = memoizeAsync(function (rootRelativeDirName, cb) {
var stale = false;
var variantInfosByBaseName = {};
async.eachLimit(roots.map(function (root) {
return Path.resolve(root, rootRelativeDirName.substr(1));
}), 1, function (dirName, cb) {
fs.readdir(dirName, function (err, fileNames) {
if (err) {
if (err.code === 'ENOENT') {
// Pretend that non-existent directories are empty
return cb();
} else {
return cb(err);
}
}
if (options.watch && !(dirName in fsWatcherByDirName)) {
try {
fsWatcherByDirName[dirName] = fs.watch(dirName, {}, function (eventName, fileName) {
getVariantInfosByBaseName.purge(rootRelativeDirName);
});
} catch (e) {
// Might fail with a ENOSPC error on certain systems. Tell the caller so the memoizer can be purged immediately
stale = true;
}
}
var statByFileName = {};
async.eachLimit(fileNames, 20, function (fileName, cb) {
var absolutePath = Path.resolve(dirName, fileName);
fs.lstat(absolutePath, function (err, stats) {
if (err) {
if (err.code === 'ENOENT') {
// Don't break if the file has been removed between readdir and lstat
return cb();
} else {
return cb(err);
}
}
if (!stats.isDirectory()) {
var matchFileName = fileName.match(/^([\w\-~%!$&'\(\)*+,;=:@]*)((?:\.[\w\-~%!$&'\(\)*+,;=:@]*)*)$/);
if (matchFileName) {
var baseName = matchFileName[1] || 'index',
extensionString = matchFileName[2],
variantInfo = {
absolutePath: absolutePath,
fileName: fileName,
unmatchedExtensionsWithoutLeadingDot: []
};
if (extensionString) {
_.extend(variantInfo, getVariantInfoFromExtensionString(extensionString, options.userAgent));
}
(variantInfosByBaseName[baseName] = variantInfosByBaseName[baseName] || []).push(variantInfo);
}
}
cb();
});
}, cb);
});
}, passError(cb, function () {
// Make sure that the variantInfo objects with the most specific locale ids come first:
Object.keys(variantInfosByBaseName).forEach(function (baseName) {
variantInfosByBaseName[baseName].sort(function (a, b) {
return (b.localeId ? b.localeId.length : 0) - (a.localeId ? a.localeId.length : 0);
});
});
cb(null, variantInfosByBaseName, stale);
}));
});
var getPrioritizedLocaleIds = memoizeSync(function (extensionLocaleId, localeParameterValue, localeCookieValue, acceptLanguageHeaderValue) {
if (extensionLocaleId) {
return [extensionLocaleId]; // /foo.en_US.html requires exact match
}
var prioritizedLocaleIds = [];
if (localeParameterValue) {
prioritizedLocaleIds.push(normalizeLocaleId(localeParameterValue));
}
if (localeCookieValue) {
localeCookieValue.split(',').map(normalizeLocaleId).forEach(function (localeId) {
Array.prototype.push.apply(prioritizedLocaleIds, expandLocaleIdToPrioritizedList(localeId));
});
}
if (acceptLanguageHeaderValue) {
acceptLanguageHeaderValue.replace(/;[^,]*/g, "").split(",").map(normalizeLocaleId).forEach(function (localeId) {
Array.prototype.push.apply(prioritizedLocaleIds, expandLocaleIdToPrioritizedList(localeId));
});
}
prioritizedLocaleIds.push('en_us', 'en');
return prioritizedLocaleIds;
});
var getUserAgentInfo = memoizeSync(function (userAgentString) {
var userAgentInfo = mario(userAgentString) || {};
// If we have IE on device other than phone always show desktop version
// We do not want IE 11 on windows surface device to get touch version
if (userAgentInfo.explorer && !userAgentInfo.windowsPhone) {
userAgentInfo.touch = false;
}
userAgentTypes.forEach(function (userAgentType) {
if (!userAgentInfo[userAgentType]) {
userAgentInfo['non' + userAgentType] = true;
}
});
return userAgentInfo;
});
return function expressNegotiator(req, res, next) {
var matchDirAndFileName = req.url.match(/^(\/(?:[\w\.\-~%!$&'\(\)*+,;=:@]+\/)*)([\w\-~%!$&'\(\)*+,;=:@]*)((?:\.[\w\-~%!$&'\(\)*+,;=:@]*)*)((?:\?.*)?)$/);
if (matchDirAndFileName) {
var baseName,
extensionString;
if (matchDirAndFileName[2]) {
try {
baseName = decodeURIComponent(matchDirAndFileName[2]);
} catch (e) {
return next();
}
} else {
baseName = 'index';
}
if (matchDirAndFileName[3]) {
try {
extensionString = decodeURIComponent(matchDirAndFileName[3]);
} catch (e2) {
return next();
}
} else {
extensionString = '';
}
var dirName = matchDirAndFileName[1],
fileName = baseName + extensionString,
queryString = matchDirAndFileName[4],
localeCookieValue = options.cookieName && req.cookies && req.cookies[options.cookieName],
matchLocaleParameterValue = queryString && queryString.match(/[&?]locale=([^&]+)(?:$|&)/),
fileNameVariantInfo = getVariantInfoFromExtensionString(extensionString),
prioritizedLocaleIds = getPrioritizedLocaleIds(fileNameVariantInfo.localeId,
matchLocaleParameterValue && matchLocaleParameterValue[1],
localeCookieValue,
req.headers['accept-language']),
acceptHeader = req.headers.accept,
acceptHeaderTokens = acceptHeader && parseQuality(acceptHeader);
return getVariantInfosByBaseName(dirName, passError(next, function (variantInfosByBaseName, stale) {
if (stale) {
getVariantInfosByBaseName.purge(dirName);
}
var variantInfos = variantInfosByBaseName[baseName],
bestVariantInfo,
bestVariantQuality,
bestVariantNumAdditionalExtensionsMatched;
if (variantInfos) {
for (var i = 0 ; i < prioritizedLocaleIds.length ; i += 1) {
var localeId = prioritizedLocaleIds[i];
for (var j = 0 ; j < variantInfos.length ; j += 1) {
var variantInfo = variantInfos[j];
if (variantInfo.fileName === fileName) {
// Exact file name match takes precedence over content negotiation
return next();
} else {
var numAdditionalExtensionsMatched = 0,
allExtensionsMatched = variantInfo.allExtensionsMatched;
if (!allExtensionsMatched) {
var extensionsWithoutLeadingDot = extensionString.replace(/^\./, '').split('.');
if (extensionsWithoutLeadingDot.every(function (extensionWithoutLeadingDot) {
return variantInfo.unmatchedExtensionsWithoutLeadingDot.indexOf(extensionWithoutLeadingDot) !== -1;
})) {
allExtensionsMatched = true;
numAdditionalExtensionsMatched = extensionsWithoutLeadingDot.length;
} else {
continue;
}
}
if (!variantInfo.localeId || localeIdsEqual(variantInfo.localeId, localeId)) {
var contentTypeQuality = 0;
if (variantInfo.contentType) {
if (fileNameVariantInfo.contentType && fileNameVariantInfo.contentType === variantInfo.contentType) {
contentTypeQuality = 2;
}
if (!contentTypeQuality) {
if (acceptHeaderTokens) {
acceptHeaderTokens.some(function (acceptHeaderToken) {
if (acceptHeaderToken.value === variantInfo.contentType ||
acceptHeaderToken.value === '*/*' ||
acceptHeaderToken.value === '*' ||
acceptHeaderToken.value === '*/' + variantInfo.contentTypeFragments[1] ||
acceptHeaderToken.value === variantInfo.contentTypeFragments[0] + '/*') {
contentTypeQuality = acceptHeaderToken.quality;
return true; // Short circuit out of the .some
}
});
} else {
contentTypeQuality = 1;
}
}
} else {
contentTypeQuality = 1;
}
if (options.userAgent && req.headers['user-agent']) {
var userAgentInfo = getUserAgentInfo(req.headers['user-agent']);
userAgentTypes.forEach(function (key) {
if (userAgentInfo[key] && variantInfo.userAgent[key]) {
contentTypeQuality += .001;
}
// Penalize the variant for each user agent type that's only in either the variant info or the User-Agent request header.
// This makes sure that /index.html is preferred over /index.mobile.html on non-mobile user agents.
if (userAgentInfo[key] ^ variantInfo.userAgent[key]) {
contentTypeQuality -= .00001;
}
});
}
// In case two acceptable entries have the same quality, prefer the text/html one.
if (contentTypeQuality > 0) {
// Find if new match is better than previous
var newIsBetter = false;
if (bestVariantInfo) {
if ((contentTypeQuality > bestVariantQuality) ||
(contentTypeQuality === bestVariantQuality && bestVariantInfo.contentType !== 'text/html' && variantInfo.contentType === 'text/html') ||
(numAdditionalExtensionsMatched > bestVariantNumAdditionalExtensionsMatched) ||
// Make sure that a strict locale id match takes precedence over an alias (in case eg. both nb and no versions are available)
(bestVariantInfo && bestVariantInfo.localeId !== localeId && variantInfo.localeId === localeId)) {
newIsBetter = true;
}
} else {
newIsBetter = true;
}
if (newIsBetter) {
bestVariantInfo = variantInfo;
bestVariantQuality = contentTypeQuality;
bestVariantNumAdditionalExtensionsMatched = numAdditionalExtensionsMatched;
}
}
}
}
}
// Don't consider the next locale id in the priority list if we've already found a matching variant:
if (bestVariantInfo) {
break;
}
}
if (bestVariantInfo) {
var targetUrl = dirName + bestVariantInfo.fileName + queryString;
req.url = targetUrl;
res.setHeader('Content-Location', targetUrl.substr(1));
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
res.setHeader('Vary', 'Cookie, Accept-Language, Accept' + (options.userAgent ? ', User-Agent': ''));
if (bestVariantInfo.localeId) {
res.setHeader('Content-Language', bestVariantInfo.localeId);
}
var etag = '';
if (bestVariantInfo.contentType && !fileNameVariantInfo.contentType) {
etag += bestVariantInfo.contentType + '-';
}
if (bestVariantInfo.localeId && !fileNameVariantInfo.localeId) {
etag += bestVariantInfo.localeId + '-';
}
// Older Express versions don't have res.locals:
(res.locals || req).variantInfo = bestVariantInfo;
if (etag.length > 0) {
return fs.stat(bestVariantInfo.absolutePath, passError(next, function (stat) {
res.setHeader('ETag', '"' + etag + stat.size + '-' + Number(stat.mtime) + '"');
// Disable conditional GET via If-Modified-Since for these requests, otherwise the static middleware
// might reply with a 304 after a locale switch.
delete req.headers['if-modified-since'];
next();
}));
} else {
next();
}
} else {
next();
}
} else {
next();
}
}));
} else {
next();
}
};
};