lasso
Version:
Lasso.js is a build tool and runtime library for building and bundling all of the resources needed by a web application
555 lines (453 loc) • 20 kB
JavaScript
require('raptor-polyfill/string/endsWith');
require('raptor-polyfill/string/startsWith');
const MAX_FILE_LENGTH = 255;
const promisify = require('pify');
var util = require('../util');
var nodePath = require('path');
var fs = require('fs');
var ok = require('assert').ok;
var logger = require('raptor-logging').logger(module);
var mkdirp = promisify(require('mkdirp'));
var crypto = require('crypto');
var raptorAsync = require('raptor-async');
const Duplex = require('stream').Duplex;
const hashUtil = require('../util/hash');
function filePathToUrlWindows(path) {
return path.replace(/[\\]/g, '/');
}
function filePathToUrlUnix(path) {
return path;
}
function enforceFileLengthLimits(path) {
return path.split(nodePath.sep).map(part => {
if (part.length < MAX_FILE_LENGTH) return part;
var overflow = part.slice(MAX_FILE_LENGTH - hashUtil.HASH_OVERFLOW_LENGTH);
var hash = hashUtil.generate(overflow);
return part.slice(0, MAX_FILE_LENGTH - hashUtil.HASH_OVERFLOW_LENGTH) + hash.slice(0, hashUtil.HASH_OVERFLOW_LENGTH);
}).join(nodePath.sep);
}
var filePathToUrl = nodePath.sep === '/' ? filePathToUrlUnix : filePathToUrlWindows;
/**
* Utility function to generate a random string of characters
* suitable for use in a filename. This function is needed
* to generate a temporary file name
*
* @param {int} len The length of the character sequence
* @return {String} The random characters with the specified length
*/
function randomStr(len) {
return crypto.randomBytes(Math.ceil(len / 2))
.toString('hex') // convert to hexadecimal format
.slice(0, len); // return required number of characters
}
/**
* This "getUrl(lassoContext)" function is added to a bundle instance
* to provide a mechanism to generate a bundle URL based on a
* base path that might change. Unless a URL prefix is specififed
* the URL is generated relative to a base path or the CWD.
*
* @param {Object} lassoContext An object with lassoContextual information needed to generate a URL
*/
function getBundleUrl(lassoContext) {
var url = this.url;
if (url) {
return url;
}
var outputFile = this.outputFile;
var urlPrefix = this.urlPrefix;
var outputDir = this.outputDir;
ok(lassoContext.config, 'lassoContext.config expected');
ok(outputFile, 'outputFile expected');
if (typeof urlPrefix === 'string') {
var relPath = filePathToUrl(outputFile.substring(outputDir.length));
if (urlPrefix.endsWith('/')) {
urlPrefix = urlPrefix.slice(0, -1);
}
url = urlPrefix + relPath;
return url;
} else {
var basePath = lassoContext.basePath ? nodePath.resolve(process.cwd(), lassoContext.basePath) : process.cwd();
return filePathToUrl(nodePath.relative(basePath, outputFile));
}
}
/**
* Internal function help write out a file and to possibly generate a fingerprint
* in the process if fingerprints are enabled.
*
* On success, the callback will be invoked with an object that contains the following
* properties:
* - fingerprint: The string fingerprint if calculateFingerprint is set to true
* - outputFile: The output file. If calculateFingerprint is set to true then the fingerprint
* will be injected into the filename
*
*
* @param {ReadableStream} inStream The input stream to read from
* @param {string} outputFile The output file path
* @param {boolean} calculateFingerprint If true then a fingerprint will be calculated and passed to the callback
* @return void
*/
async function writeFile (inStream, outputFile, calculateFingerprint, fingerprintLength) {
var outputDir = nodePath.dirname(outputFile);
var done = false;
await mkdirp(outputDir);
var outStream;
var tempFile = outputFile + '.' + process.pid + '.' + randomStr(4);
return new Promise((resolve, reject) => {
function handleError(err) {
if (done) {
return;
}
done = true;
reject(err);
}
function handleSuccess(result) {
if (done) {
return;
}
done = true;
resolve(result);
}
if (calculateFingerprint) {
logger.debug(`Writing bundle to temp file ${tempFile}... (calculating fingerprint)`);
// Pipe the stream to a temporary file and when the fingerprint is known,
// rename the file to include the known fingerprint
var fingerprint = fingerprint;
outStream = fs.createWriteStream(tempFile);
var fingerprintStream = util.createFingerprintStream();
outStream
.on('close', function() {
if (done) {
return;
}
if (fingerprintLength && fingerprint.length > fingerprintLength) {
fingerprint = fingerprint.substring(0, fingerprintLength);
}
var ext = nodePath.extname(outputFile);
outputFile = outputFile.slice(0, 0 - ext.length) + '-' + fingerprint + ext;
fs.stat(outputFile, function (error, stats) {
if (error) {
fs.rename(tempFile, outputFile, function(err) {
if (err && !fs.existsSync(outputFile)) {
return handleError(err);
}
handleSuccess({
fingerprint: fingerprint,
outputFile: outputFile
});
});
} else {
// If it already exists then just use that file, but delete the temp file
fs.unlink(tempFile, function() {
handleSuccess({
fingerprint: fingerprint,
outputFile: outputFile
});
});
}
});
});
fingerprintStream
.on('fingerprint', function(_fingerprint) {
fingerprint = _fingerprint;
})
.on('error', handleError)
.pipe(outStream);
inStream
.on('error', handleError)
.pipe(fingerprintStream);
} else {
logger.debug(`Writing bundle to temp file ${tempFile}... (no fingerprint)`);
// No fingerprint is needed so simply pipe out the input stream
// to the output file
outStream = fs.createWriteStream(tempFile);
inStream
.on('error', handleError)
.pipe(outStream)
.on('close', function() {
if (done) {
return;
}
fs.rename(tempFile, outputFile, function(err) {
if (err && !fs.existsSync(outputFile)) {
return handleError(err);
}
handleSuccess({
outputFile: outputFile
});
});
});
}
});
}
module.exports = function fileWriter(fileWriterConfig, lassoConfig) {
// The directory to place the built bundle and resource files
var outputDir = nodePath.resolve(process.cwd(), fileWriterConfig.outputDir || 'static');
// Boolean value to indicate if including fingerprints in the output files is enabled
// or not.
var fingerprintsEnabled = fileWriterConfig.fingerprintsEnabled !== false;
// Optional URL prefix to use when generating URLs to the bundled files
var urlPrefix = fileWriterConfig.urlPrefix;
// Boolean value to indicate if the target slot should be added to the output filename
// e.g. "head" or "body"
var includeSlotNames = fileWriterConfig.includeSlotNames;
// If fingerprints are enabled then this flag will be used to determine how many characters
// the fingerprint should contain
var fingerprintLength = fileWriterConfig.fingerprintLength || 8;
// Boolean value to indicate if CSS URLs should be relative
var relativeUrlsEnabled = fileWriterConfig.relativeUrlsEnabled;
/**
* Calculate the output file for a given bundle given
* the configuration for the file writer.
*
* @param {lasso/src/Bundle} bundle The lasso Bundle
* @return {String} The output file path for the bundle
*/
function getOutputFileForBundle(bundle) {
if (bundle.outputFile) {
return bundle.outputFile;
}
var relativePath;
if (bundle.dependency && bundle.dependency.getSourceFile) {
relativePath = bundle.dependency.getSourceFile();
} else if (bundle.relativeOutputPath) {
// We are being told to use a relative path that
// we should join with the output directory
relativePath = bundle.relativeOutputPath;
}
var targetExt = bundle.getContentType();
return getOutputFile(
relativePath,
bundle.getName(),
targetExt,
bundle.getSlot());
}
function getOutputFileFromFileName (path, relativePath) {
const filename = nodePath.basename(path);
return getOutputFile(
relativePath,
filename);
}
function getOutputFileForResource(path, fingerprintsEnabled, lassoContext) {
var relativePath;
if (lassoConfig.isBundlingEnabled() === false || fingerprintsEnabled === false) {
// When bundling is disabled we maintain the directory structure
// so we want to provide the sourceFile so that we can
// know which deeply nested resource path to use
relativePath = lassoContext.getClientPath(path);
var pageName = lassoContext.pageName;
var flags = lassoContext.flags;
if (pageName) {
var prefix = pageName.replace(/[\\\/]/g, '-');
if (flags && !flags.isEmpty()) {
prefix += '-' + flags.getKey();
}
relativePath = prefix + '/' + relativePath;
}
}
return getOutputFileFromFileName(path, relativePath);
}
function buildResourceCacheKey(cacheKey, lassoContext) {
var lassoConfig = lassoContext.config;
var pageName = lassoContext.pageName;
if (pageName && (lassoConfig.isBundlingEnabled() === false || fingerprintsEnabled === false)) {
return pageName + '-' + cacheKey;
}
return cacheKey;
}
function getOutputFile(relativePath, filename, targetExt, slotName) {
var outputPath;
if (relativePath) {
outputPath = nodePath.join(outputDir, relativePath);
}
if (!outputPath) {
ok(filename, '"filename" or "sourceFile" expected');
outputPath = nodePath.join(outputDir, filename.replace(/^\//, '').replace(/[^A-Za-z0-9_\-\.]/g, '-'));
}
var dirname = nodePath.dirname(outputPath);
var basename = nodePath.basename(outputPath);
var lastDot = basename.lastIndexOf('.');
var ext;
var nameNoExt;
if (lastDot !== -1) {
ext = basename.substring(lastDot + 1);
nameNoExt = basename.substring(0, lastDot);
} else {
ext = '';
nameNoExt = basename;
}
if (includeSlotNames && slotName) {
nameNoExt += '-' + slotName;
}
basename = nameNoExt;
if (ext) {
basename += '.' + ext;
}
if (targetExt && ext != targetExt) { // eslint-disable-line eqeqeq
basename += '.' + targetExt;
}
return enforceFileLengthLimits(nodePath.join(dirname, basename));
}
function getResourceUrlForHashed (path, lassoContext) {
var basePath;
if (lassoContext && lassoContext.bundle && lassoContext.bundle.isStyleSheet() && relativeUrlsEnabled !== false) {
// We should calculate a relative path from the CSS bundle to the resource bundle
basePath = nodePath.dirname(getOutputFileForBundle(lassoContext.bundle));
return filePathToUrl(nodePath.relative(basePath, path));
}
if (typeof urlPrefix === 'string') {
var relPath = filePathToUrl(path.substring(outputDir.length));
if (urlPrefix.endsWith('/')) {
urlPrefix = urlPrefix.slice(0, -1);
}
return urlPrefix + relPath;
} else {
basePath = lassoContext.basePath ? nodePath.resolve(process.cwd(), lassoContext.basePath) : process.cwd();
return filePathToUrl(nodePath.relative(basePath, path));
}
}
function getResourceUrl (path, lassoContext) {
ok(path, 'path is required');
ok(path.startsWith(outputDir), 'resource expected to be in the output directory. path=' + path + ', outputDir=' + outputDir);
return getResourceUrlForHashed(path, lassoContext);
}
return {
buildResourceCacheKey,
/**
* This method is used to determine if writing a bundle
* should be bypassed.
*
* @param {lasso/src/Bundle} The bundle instance
* @param {Object} Contextual information
* @return {[type]} [description]
*/
async checkBundleUpToDate (bundle, lassoContext) {
return false;
// NOTE: We used to do a last modified check based on file modified datas,
// but there were edge cases that caused problem. For example, even though
// none of the files in the bundle were modified, a separate file that impacts
// how the bundle code may be generated may have been modified. The
// saves from a timestamp check are minimal.
},
checkResourceUpToDate: function(path, lassoContext, callback) {
if (fingerprintsEnabled) {
return callback(null, false);
}
var outputFile = getOutputFileForResource(path, fingerprintsEnabled, lassoContext);
var work = {
async sourceLastModified () {
return lassoContext.getFileLastModified(path);
},
async outputLastModified (callback) {
return lassoContext.getFileLastModified(outputFile);
}
};
raptorAsync.parallel(work, function(err, results) {
if (err) {
return callback(err);
}
if (results.outputLastModified >= results.sourceLastModified) {
// The resource has not been modified so let the lasso
// know what URL to use for the resource
var url = getResourceUrl(outputFile, lassoContext);
callback(null, {
url: url,
outputFile: outputFile
});
} else {
// Resource is not up-to-date and needs to be written
callback(null, false);
}
});
},
async writeBundle (reader, lassoContext) {
return new Promise((resolve, reject) => {
var input = reader.readBundle();
var bundle = lassoContext.bundle;
ok(input, '"input" is required');
ok(bundle, '"bundle" is required');
input.on('error', reject);
var calculateFingerprint = bundle.config.fingerprintsEnabled;
if (calculateFingerprint === undefined) {
calculateFingerprint = fingerprintsEnabled;
}
calculateFingerprint = calculateFingerprint === true && !bundle.getFingerprint();
var outputFile = getOutputFileForBundle(bundle);
logger.debug('Writing bundle "' + bundle.getLabel() + '" to file "' + outputFile + '"...');
writeFile(input, outputFile, calculateFingerprint, fingerprintLength)
.then((result) => {
logger.debug('Writing bundle "' + bundle.getLabel() + '" to file "' + outputFile + '" COMPLETED');
bundle.setFingerprint(result.fingerprint);
bundle.setWritten(true);
bundle.getUrl = getBundleUrl;
bundle.urlPrefix = urlPrefix;
bundle.outputDir = outputDir;
bundle.outputFile = result.outputFile;
resolve();
}).catch((err) => {
logger.debug('Writing bundle "' + bundle.getLabel() + '" to file "' + outputFile + '" FAILED! - Error:', err);
reject(err);
});
});
},
async writeResource (reader, lassoContext) {
let done = false;
const input = reader.readResource();
const path = lassoContext.path;
ok(input, '"input" is required');
ok(path, '"path" is required');
return new Promise((resolve, reject) => {
function handleError (err) {
if (done) {
return;
}
done = true;
reject(err);
}
input.on('error', handleError);
const calculateFingerprint = fingerprintsEnabled === true;
const outputFileForResource = getOutputFileForResource(path, calculateFingerprint, lassoContext);
writeFile(
input,
outputFileForResource,
calculateFingerprint,
fingerprintLength)
.then((result) => {
const outputFile = result.outputFile;
const url = getResourceUrl(outputFile, lassoContext);
resolve({ url, outputFile });
}).catch(handleError);
});
},
async writeResourceBuffer (buff, lassoContext) {
let done = false;
const input = new Duplex();
input.push(buff);
input.push(null);
const path = lassoContext.path;
ok(input, '"input" is required');
ok(path, '"path" is required');
return new Promise((resolve, reject) => {
function handleError (err) {
if (done) {
return;
}
done = true;
reject(err);
}
input.on('error', handleError);
const calculateFingerprint = false;
const outputFileForResource = getOutputFileFromFileName(path, null);
writeFile(
input,
outputFileForResource,
calculateFingerprint,
fingerprintLength)
.then((result) => {
const outputFile = result.outputFile;
const url = getResourceUrlForHashed(outputFile, lassoContext);
resolve({ url, outputFile });
}).catch(handleError);
});
}
};
};