const pathModule = require('path');
const { promisify } = require('util');
const fs = require('fs');
const stat = promisify(fs.stat);
const readFile = promisify(fs.readFile);
const EventEmitter = require('events').EventEmitter;
const crypto = require('crypto');
const _ = require('lodash');
const extendDefined = require('../util/extendDefined');
const urlTools = require('urltools');
const urlModule = require('url');
const qs = require('qs');
const AssetGraph = require('../AssetGraph');
const knownAndUnsupportedProtocols = require('schemes').allByName;
const urlEndsWithSlashRegExp = /\/(?:[?#].*)?$/;
const { URL } = require('url');
/**
* Configuration object used to construct Assets in all places where an asset is automatically
* constructed. For example in [AssetGraph.addAsset]{@link AssetGraph#addAsset}
* or in the `to`-property in [Asset.addRelation]{@link Asset#addRelation}
*
*
* @typedef {Object} AssetConfig
*
* @property {String} [type] The Assets type. Will be inferred if missing
*
* @property {Buffer} [rawSrc] `Buffer` object containing the raw source of the asset
*
* @property {String} [contentType] The Content-Type (MIME type) of the asset. For
* subclasses of Asset there will be a reasonable default.
*
* @property {String} [url] The fully qualified (absolute) url of the asset.
* If not provided, the asset will be considered inline. This property takes precedence
* over all other url parts in the configuration
*
* @property {String} [fileName]
* @property {String} [baseName]
* @property {String} [extension]
* @property {String} [protocol]
* @property {String} [username]
* @property {String} [password]
* @property {String} [hostname]
* @property {Number} [port]
* @property {String} [path]
*/
/**
* An asset object represents a single node in an AssetGraph, but can
* be used and manipulated on its own outside the graph context.
*/
class Asset extends EventEmitter {
static registerRelation(Class) {
this.relations.add(Class);
AssetGraph.registerRelation(Class);
}
/**
* Create a new Asset instance.
*
* Most of the time it's unnecessary to create asset objects
* directly. When you need to manipulate assets that already exist on
* disc or on a web server, the `loadAssets` and `populate` transforms
* are the easiest way to get the objects created. See the section about
* transforms below.
*
* Note that the Asset base class is only intended to be used to
* represent assets for which there's no specific subclass.
*
* @constructor
* @param {AssetConfig} config Configuration for instantiating an asset
* @param {AssetGraph} assetGraph Mandatory AssetGraph instance references
*/
constructor(config, assetGraph) {
super();
if (!assetGraph) {
throw new Error('2nd argument (assetGraph) is mandatory');
}
this.assetGraph = assetGraph;
if (config.id) {
this.id = config.id;
} else {
this.id = `${_.uniqueId()}`;
}
this.init(config);
}
init(config = {}) {
if (typeof config.lastKnownByteLength === 'number') {
this._lastKnownByteLength = config.lastKnownByteLength;
}
if (config.type) {
this._type = config.type;
}
if (config.rawSrc) {
this._rawSrc = config.rawSrc;
this._consumer = undefined; // SourceMap
this._updateRawSrcAndLastKnownByteLength(config.rawSrc);
} else if (config.parseTree !== undefined || config.text !== undefined) {
this._rawSrc = undefined;
}
if (typeof config.text === 'string') {
this._text = config.text;
this._consumer = undefined; // SourceMap
} else if (config.parseTree !== undefined || config.rawSrc !== undefined) {
this._text = undefined;
}
if (config.parseTree) {
this._parseTree = config.parseTree;
this._consumer = undefined; // SourceMap
} else if (config.text !== undefined || config.rawSrc !== undefined) {
this._parseTree = undefined;
}
if (config.encoding) {
this._encoding = config.encoding;
}
if (config.sourceMap) {
this._sourceMap = config.sourceMap;
}
if (config.url) {
this._url = config.url.trim();
this._updateUrlIndex(this._url);
if (!urlEndsWithSlashRegExp.test(this._url)) {
const urlObj = urlTools.parse(this._url);
// Guard against mailto: and the like
if (/^(?:https?|file):/.test(urlObj.protocol)) {
const pathname = urlTools.parse(this._url).pathname;
this._extension = pathModule.extname(pathname);
this._fileName = pathModule.basename(pathname);
this._baseName = pathModule.basename(pathname, this._extension);
}
}
extendDefined(
this,
_.omit(config, [
'rawSrc',
'parseTree',
'text',
'encoding',
'sourceMap',
'lastKnownByteLength',
'url',
'fileName',
'extension',
])
);
} else {
if (
typeof config.fileName === 'string' &&
typeof this._fileName !== 'string'
) {
this._fileName = config.fileName;
this._extension = pathModule.extname(this._fileName);
}
for (const propertyName of [
'baseName',
'extension',
'protocol',
'username',
'password',
'hostname',
'port',
]) {
if (
typeof config[propertyName] !== 'undefined' &&
typeof this[`_${propertyName}`] === 'undefined'
) {
this[`_${propertyName}`] = config[propertyName];
}
}
if (
typeof config.path !== 'undefined' &&
typeof this._path === 'undefined'
) {
this._path = config.path;
}
extendDefined(
this,
_.omit(config, [
'rawSrc',
'parseTree',
'text',
'encoding',
'sourceMap',
'lastKnownByteLength',
'url',
'fileName',
'extension',
'protocol',
'username',
'password',
'hostname',
'port',
'path',
'id',
])
);
}
}
_inferType(incomingRelation) {
if (!this._inferredType) {
let typeFromContentType;
if (Object.prototype.hasOwnProperty.call(this, 'contentType')) {
typeFromContentType = AssetGraph.lookupContentType(this.contentType);
if (typeFromContentType) {
this._inferredType = typeFromContentType;
}
}
if (
!this._inferredType ||
this._inferredType === 'Image' ||
this._inferredType === 'Font'
) {
let firstFoundTargetType;
const incomingRelations = this.incomingRelations;
if (incomingRelation) {
incomingRelations.push(incomingRelation);
}
for (const incomingRelation of incomingRelations) {
if (incomingRelation.targetType) {
firstFoundTargetType = incomingRelation.targetType;
break;
}
}
if (firstFoundTargetType) {
if (
!this._inferredType ||
AssetGraph[firstFoundTargetType].prototype[
`is${this._inferredType}`
]
) {
this._inferredType = firstFoundTargetType;
}
}
}
const typeFromExtension =
this.url &&
AssetGraph.typeByExtension[
pathModule.extname(this.url.replace(/[?#].*$/, '')).toLowerCase()
];
if (
typeFromExtension &&
// avoid upgrading from explicit Content-Type: text/plain to eg. JavaScript based on the file extension
(!this._inferredType ||
(AssetGraph[typeFromExtension].prototype[`is${this._inferredType}`] &&
typeFromContentType !== 'Text'))
) {
this._inferredType = typeFromExtension;
}
}
return this._inferredType;
}
_upgrade(Class) {
Object.setPrototypeOf(this, Class.prototype);
this.init();
// This is a smell: Maybe we should find a way to avoid populating non-upgraded Asset instances,
// but what about HttpRedirect and FileRedirect, then?
this.isPopulated = false;
this._outgoingRelations = undefined;
}
_tryUpgrade(type, incomingRelation) {
type = type || this._inferType(incomingRelation);
if (type === this.constructor.name) {
return;
}
if (
/^https?:/.test(this.protocol) &&
!Object.prototype.hasOwnProperty.call(this, 'contentType') &&
!this._type
) {
return;
}
const Class = AssetGraph[type];
if (Class) {
// Allow upgrading from Image to Svg, or from Font to Svg:
if (
Class.prototype.type !== this.type &&
Class.prototype[`is${this.type}`]
) {
this._upgrade(Class);
} else {
// Only allow upgrading from a superclass:
let superclass = Object.getPrototypeOf(Class);
while (superclass) {
if (superclass === this.constructor) {
this._upgrade(Class);
break;
}
superclass = Object.getPrototypeOf(superclass);
}
}
}
}
/**
* The assets defined or inferred type
*
* @type {String}
*/
get type() {
if (this._type) {
return this._type;
} else {
return this._inferType();
}
}
/**
* The default extension for the asset type, prepended with a dot, eg. `.html`, `.js` or `.png`
*
* @type {String}
*/
get defaultExtension() {
return (this.supportedExtensions && this.supportedExtensions[0]) || '';
}
/**
* Some asset classes support inspection and manipulation using a high
* level interface. If you modify the parse tree, you have to call
* `asset.markDirty()` so any cached serializations of the asset are
* invalidated.
*
* These are the formats you'll get:
*
* `Html` and `Xml`:
* jsdom document object (https://github.com/tmpvar/jsdom).
*
* `Css`
* CSSOM CSSStyleSheet object (https://github.com/NV/CSSOM).
*
* `JavaScript`
* estree AST object (via acorn).
*
* `Json`
* Regular JavaScript object (the result of JSON.parse on the decoded source).
*
* `CacheManifest`
* A JavaScript object with a key for each section present in the
* manifest (`CACHE`, `NETWORK`, `REMOTE`). The value is an array with
* an item for each entry in the section. Refer to the source for
* details.
*
* @member {Oject} Asset#parseTree
*/
/**
* Load the Asset
*
* Returns a promise that is resolved when the asset is loaded.
* This is Asset's only async method, as soon as it is
* loaded, everything can happen synchronously.
*
* Usually you'll want to use `transforms.loadAssets`, which will
* handle this automatically.
*
* @async
* @return {Promise<Asset>} The loaded Asset
*/
async load({ metadataOnly = false } = {}) {
try {
if (!this.isLoaded) {
if (!this.url) {
throw new Error('Asset.load: No url, cannot load');
}
const url = this.url;
const protocol = url.substr(0, url.indexOf(':')).toLowerCase();
if (protocol === 'file') {
const pathname = urlTools.fileUrlToFsPath(url);
if (metadataOnly) {
const stats = await stat(pathname);
if (stats.isDirectory()) {
this.fileRedirectTargetUrl = urlTools.fsFilePathToFileUrl(
pathname.replace(/(\/)?$/, '/index.html')
);
// Make believe it's loaded:
this._rawSrc = Buffer.from([]);
}
} else {
try {
this._rawSrc = await readFile(pathname);
this._updateRawSrcAndLastKnownByteLength(this._rawSrc);
} catch (err) {
if (err.code === 'EISDIR' || err.code === 'EINVAL') {
this.fileRedirectTargetUrl = urlTools.fsFilePathToFileUrl(
pathname.replace(/(\/)?$/, '/index.html')
);
this.isRedirect = true;
} else {
throw err;
}
}
}
} else if (protocol === 'http' || protocol === 'https') {
const { headers = {}, ...requestOptions } =
this.assetGraph.requestOptions || {};
const firstIncomingRelation = this.incomingRelations[0];
let Referer;
if (
firstIncomingRelation &&
firstIncomingRelation.from.protocol &&
firstIncomingRelation.from.protocol.startsWith('http')
) {
Referer = firstIncomingRelation.from.url;
}
const response = await this.assetGraph.teepee.request({
...requestOptions,
headers: {
...headers,
Referer,
},
method: metadataOnly ? 'HEAD' : 'GET',
url,
json: false,
});
this.statusCode = response.statusCode;
if (!metadataOnly) {
this._rawSrc = response.body;
this._updateRawSrcAndLastKnownByteLength(this._rawSrc);
}
if (
response.headers.location &&
[301, 302, 303, 307, 308].includes(this.statusCode)
) {
this.location = response.headers.location;
this.isRedirect = true;
}
const contentTypeHeaderValue = response.headers['content-type'];
if (contentTypeHeaderValue) {
const matchContentType = contentTypeHeaderValue.match(
/^\s*([\w\-+.]+\/[\w-+.]+)(?:\s|;|$)/i
);
if (matchContentType) {
this.contentType = matchContentType[1].toLowerCase();
const matchCharset = contentTypeHeaderValue.match(
/;\s*charset\s*=\s*(['"]|)\s*([\w-]+)\s*\1(?:\s|;|$)/i
);
if (matchCharset) {
this._encoding = matchCharset[2].toLowerCase();
}
} else {
const err = new Error(
`Invalid Content-Type response header received: ${contentTypeHeaderValue}`
);
err.asset = this;
this.assetGraph.warn(err);
}
} else if (response.statusCode >= 200 && response.statusCode < 300) {
const err = new Error('No Content-Type response header received');
err.asset = this;
this.assetGraph.warn(err);
}
if (response.headers.etag) {
this.etag = response.headers.etag;
}
if (response.headers['cache-control']) {
this.cacheControl = response.headers['cache-control'];
}
if (response.headers['content-security-policy']) {
this.contentSecurityPolicy =
response.headers['content-security-policy'];
}
if (response.headers['content-security-policy-report-only']) {
this.contentSecurityPolicyReportOnly =
response.headers['content-security-policy-report-only'];
}
for (const headerName of ['date', 'last-modified']) {
if (response.headers[headerName]) {
this[
headerName.replace(/-([a-z])/, ($0, ch) => ch.toUpperCase())
] = new Date(response.headers[headerName]);
}
}
} else if (!knownAndUnsupportedProtocols[protocol]) {
const err = new Error(
`No resolver found for protocol: ${protocol}\n\tIf you think this protocol should exist, please contribute it here:\n\thttps://github.com/Munter/schemes#contributing`
);
if (this.assetGraph) {
this.assetGraph.warn(err);
} else {
throw err;
}
}
}
// Try to upgrade to a subclass based on the currently available type information:
this._inferredType = undefined;
const type = this._inferType();
this._tryUpgrade(type);
this.emit('load', this);
if (this.assetGraph) {
this.populate(true);
}
return this;
} catch (err) {
if (!err.message) {
err.message = err.code || err.name;
}
err.asset = this;
throw err;
}
}
/**
* The loaded state of the Asset
*
* @type {Boolean}
*/
get isLoaded() {
return (
this._rawSrc !== undefined ||
this._parseTree !== undefined ||
this.isRedirect ||
typeof this._text === 'string'
);
}
/**
* Get the first non-inline ancestor asset by following the
* incoming relations, ie. the first asset that has a
* url. Returns the asset itself if it's not inline, and null if
* the asset is inline, but not in an AssetGraph.
*
* @type {?Asset}
*/
get nonInlineAncestor() {
if (!this.isInline) {
return this;
}
if (
this.incomingInlineRelation &&
this.incomingInlineRelation.from !== this
) {
return this.incomingInlineRelation.from.nonInlineAncestor;
} else if (this.assetGraph) {
const incomingRelations = this.incomingRelations.filter(
(r) => r.from !== r._to
);
if (incomingRelations.length > 0) {
return incomingRelations[0].from.nonInlineAncestor;
}
}
return null;
}
get baseUrl() {
if (this.isInline) {
const nonInlineAncestor = this.nonInlineAncestor;
if (nonInlineAncestor) {
return this.nonInlineAncestor.baseUrl;
}
} else {
return this.url;
}
}
/**
* The file name extension for the asset (String). It is
* automatically kept in sync with the url, but preserved if the
* asset is inlined or set to a value that ends with a slash.
*
* If updated, the url of the asset will also be updated.
*
* The extension includes the leading dot and is thus kept in the
* same format as `require('path').extname` and the `basename`
* command line utility use.
*
* @type {String}
*/
get extension() {
if (typeof this._extension === 'string') {
return this._extension;
} else {
return this.defaultExtension;
}
}
set extension(extension) {
if (!this.isInline) {
this.url = this.url.replace(/(?:\.\w+)?([?#]|$)/, `${extension}$1`);
} else if (typeof this._fileName === 'string') {
this._fileName =
pathModule.basename(this._fileName, this._extension) + extension;
}
this._extension = extension;
}
/**
* The file name for the asset. It is automatically kept
* in sync with the url, but preserved if the asset is inlined or
* set to a value that ends with a slash.
*
* If updated, the url of the asset will also be updated.
*
* @type {String}
*/
get fileName() {
if (typeof this._fileName === 'string') {
return this._fileName;
}
}
set fileName(fileName) {
if (!this.isInline) {
this.url = this.url.replace(/[^/?#]*([?#]|$)/, `${fileName}$1`);
}
this._extension = pathModule.extname(fileName);
this._baseName = pathModule.basename(fileName, this._extension);
this._fileName = fileName;
}
/**
* The file name for the asset, excluding the extension. It is automatically
* kept in sync with the url, but preserved if the asset is inlined or
* set to a value that ends with a slash.
*
* If updated, the url of the asset will also be updated.
*
* @type {String}
*/
get baseName() {
if (typeof this._baseName === 'string') {
return this._baseName;
}
}
set baseName(baseName) {
if (!this.isInline) {
this.url = this.url.replace(
/[^/?#]*([?#]|$)/,
`${baseName + this.extension}$1`
);
}
this._fileName = baseName + this.extension;
}
/**
* The path of the asset relative to the AssetGraph root.
* Corresponds to a `new URL(...).pathName`
*
* If updated, the url of the asset will also be updated.
*
* @type {String}
*/
get path() {
const currentValue = this._path;
if (typeof currentValue !== 'undefined') {
return currentValue;
}
const url = this.url;
if (url) {
let value;
if (url.startsWith(this.assetGraph.root)) {
value = url.substr(this.assetGraph.root.length - 1);
} else {
value = new urlModule.URL(url).pathname;
}
return value.replace(/\/+[^/]+$/, '/') || '/';
}
}
set path(value) {
const url = this.url;
if (url) {
if (url.startsWith(this.assetGraph.root)) {
this.url =
this.assetGraph.root +
value.replace(/^\//, '').replace(/\/?$/, '/') +
(this.fileName || '');
} else {
const urlObj = new urlModule.URL(url);
urlObj.pathname = value.replace(/\/?$/, '/') + (this.fileName || '');
this.url = urlObj.toString();
}
} else if (this.isInline) {
throw new Error('Cannot update the path of an inline asset');
}
}
/**
* The origin of the asset, `protocol://host`
* Corresponds to `new URL(...).origin`
*
* For inlined assets, this will contain the origin
* of the first non-inlined ancestor
*
* @type {String}
*/
get origin() {
const nonInlineAncestor = this.nonInlineAncestor;
if (nonInlineAncestor) {
const urlObj = new urlModule.URL(nonInlineAncestor.url);
const slashes = nonInlineAncestor.url.startsWith(`${urlObj.protocol}//`);
if (urlObj) {
return urlObj.protocol + (slashes ? '//' : '') + (urlObj.host || '');
}
}
}
/**
* Get or set the raw source of the asset.
*
* If the internal state has been changed since the asset was
* initialized, it will automatically be reserialized when this
* property is retrieved.
*
* @example
* const htmlAsset = new AssetGraph().addAsset({
* type: 'Html',
* rawSrc: new Buffer('<html><body>Hello!</body></html>')
* });
* htmlAsset.parseTree.body.innerHTML = "Bye!";
* htmlAsset.markDirty();
* htmlAsset.rawSrc.toString(); // "<html><body>Bye!</body></html>"
*
* @type {Buffer}
*/
get rawSrc() {
if (this._rawSrc) {
return this._rawSrc;
}
const err = new Error(`Asset.rawSrc getter: Asset isn't loaded: ${this}`);
if (this.assetGraph) {
this.assetGraph.warn(err);
} else {
throw err;
}
}
set rawSrc(rawSrc) {
this.unload();
this._updateRawSrcAndLastKnownByteLength(rawSrc);
if (this.assetGraph) {
this.populate();
}
this.markDirty();
}
/**
* The `data:`-url of the asset for inlining
*
* @type {String}
*/
get dataUrl() {
if (this.isText) {
const text = this.text;
const urlEncodedText = encodeURIComponent(text).replace(/%2C/g, ',');
const isUsAscii = !/[\x80-\uffff]/.test(text);
const charsetParam = isUsAscii ? '' : ';charset=UTF-8';
if (
urlEncodedText.length + charsetParam.length <
';base64'.length + this.rawSrc.length * 1.37
) {
return `data:${this.contentType}${
isUsAscii ? '' : ';charset=UTF-8'
},${urlEncodedText}`;
}
}
// Default to base64 encoding:
return `data:${this.contentType};base64,${this.rawSrc.toString('base64')}`;
}
_updateRawSrcAndLastKnownByteLength(rawSrc) {
this._rawSrc = rawSrc;
this._lastKnownByteLength = rawSrc.length;
}
/**
* Get the last known byt length of the Asset
*
* Doesn't force a serialization of the asset if a value has previously been recorded.
*
* @type {Number}
*/
get lastKnownByteLength() {
if (this._rawSrc) {
return this._rawSrc.length;
} else if (typeof this._lastKnownByteLength === 'number') {
return this._lastKnownByteLength;
} else {
return this.rawSrc.length; // Force the rawSrc to be computed
}
}
/**
* Externalize an inlined Asset.
*
* This will create an URL from as many available URL parts as possible and
* auto generate the rest, then assign the URL to the Asset
*/
externalize() {
let urlObj;
if (typeof this._path === 'string') {
urlObj = new urlModule.URL(
this.assetGraph.root + this._path.replace(/^\//, '')
);
} else {
urlObj = new urlModule.URL(this.assetGraph.root);
}
for (const urlPropertyName of [
'username',
'password',
'hostname',
'port',
'protocol',
]) {
const value = this[`_${urlPropertyName}`];
if (typeof value !== 'undefined') {
urlObj[urlPropertyName] = value;
}
}
let baseName;
let extension;
if (typeof this._fileName === 'string') {
extension = pathModule.extname(this._fileName);
baseName = pathModule.basename(this._fileName, extension);
} else {
baseName = this.baseName || this.id;
extension = this.extension;
if (typeof extension === 'undefined') {
extension = this.defaultExtension;
}
}
if (this._query) {
urlObj.search = this._query;
}
const pathnamePrefix = urlObj.pathname.replace(/\/+[^/]+$/, '/') || '/';
urlObj.pathname = pathnamePrefix + baseName + extension;
this.url = urlObj.toString();
}
/**
* Unload the asset body. If the asset is in a graph, also
* remove the relations from the graph along with any inline
* assets.
* Also used internally to clean up before overwriting
* .rawSrc or .text.
*/
unload() {
this._unpopulate();
this._rawSrc = undefined;
this._text = undefined;
this._parseTree = undefined;
}
/**
* Get the current md5 hex of the asset.
*
* @type {String}
*/
get md5Hex() {
if (!this._md5Hex) {
this._md5Hex = crypto.createHash('md5').update(this.rawSrc).digest('hex');
}
return this._md5Hex;
}
/**
* Get or set the absolute url of the asset.
*
* The url will use the `file:` schema if loaded from disc. Will be
* falsy for inline assets.
*
* @type {String}
*/
get url() {
return this._url;
}
_updateUrlIndex(newUrl, oldUrl) {
if (this.assetGraph) {
if (oldUrl) {
if (this.assetGraph._urlIndex[oldUrl] !== this) {
throw new Error(`${oldUrl} was not in the _urlIndex`);
}
delete this.assetGraph._urlIndex[oldUrl];
}
if (newUrl) {
if (this.assetGraph._urlIndex[newUrl]) {
if (this.assetGraph._urlIndex[newUrl] !== this) {
throw new Error(
`${newUrl} already exists in the graph, cannot update url`
);
}
} else {
this.assetGraph._urlIndex[newUrl] = this;
}
}
}
}
set url(url) {
if (!this.isExternalizable) {
throw new Error(
`${this.toString()} cannot set url of non-externalizable asset`
);
}
const oldUrl = this._url;
if (url && !/^[a-z+]+:/.test(url)) {
// Non-absolute
const baseUrl =
oldUrl ||
(this.assetGraph && this.baseAsset && this.baseUrl) ||
(this.assetGraph && this.assetGraph.root);
if (!baseUrl) {
throw new Error(
`Cannot find base url for resolving new url of ${this.urlOrDescription} to non-absolute: ${url}`
);
}
if (/^\/\//.test(url)) {
// Protocol-relative
url = urlTools.resolveUrl(baseUrl, url);
} else if (/^\//.test(url)) {
// Root-relative
if (/^file:/.test(baseUrl) && /^file:/.test(this.assetGraph.root)) {
url = urlTools.resolveUrl(this.assetGraph.root, url.substr(1));
} else {
url = urlTools.resolveUrl(baseUrl, url);
}
} else {
// Relative
url = urlTools.resolveUrl(baseUrl, url);
}
}
if (url !== oldUrl) {
const existingAsset = this.assetGraph._urlIndex[url];
if (existingAsset) {
// Move the existing asset at that location out of the way
let nextSuffixToTry = 1;
let newUrlForExistingAsset;
do {
const urlObj = new URL(url);
urlObj.pathname = urlObj.pathname.replace(
/([^/]*?)(\.[^/]*)?$/,
($0, $1, $2) => `${$1}-${nextSuffixToTry}${$2 || ''}`
);
newUrlForExistingAsset = urlObj.toString();
nextSuffixToTry += 1;
} while (this.assetGraph._urlIndex[newUrlForExistingAsset]);
existingAsset.url = newUrlForExistingAsset;
}
this._url = url;
this._query = undefined;
this._updateUrlIndex(url, oldUrl);
if (url) {
this.incomingInlineRelation = undefined;
if (!urlEndsWithSlashRegExp.test(url)) {
const pathname = urlTools.parse(url).pathname;
this._extension = pathModule.extname(pathname);
this._fileName = pathModule.basename(pathname);
this._baseName = pathModule.basename(pathname, this._extension);
}
}
if (this.assetGraph) {
for (const incomingRelation of this.assetGraph.findRelations({
to: this,
})) {
incomingRelation.refreshHref();
}
for (const relation of this.externalRelations) {
relation.refreshHref();
}
}
}
}
/**
* Determine whether the asset is inline (shorthand for checking
* whether it has a url).
*
* @type {Boolean}
*/
get isInline() {
return !this.url;
}
/**
* Sets the `dirty` flag of the asset, which is the way to say
* that the asset has been manipulated since it was first loaded
* (read from disc or loaded via http). For inline assets the flag
* is set if the asset has been manipulated since it was last
* synchronized with (copied into) its containing asset.
*
* For assets that support a `text` or `parseTree` property, calling
* `markDirty()` will invalidate any cached serializations of the
* asset.
*
* @return {Asset} The asset itself (chaining-friendly)
*/
markDirty() {
this.isDirty = true;
if (typeof this._text === 'string' || this._parseTree !== undefined) {
this._rawSrc = undefined;
}
this._md5Hex = undefined;
if (this.isInline && this.assetGraph && this.incomingInlineRelation) {
// Cascade dirtiness to containing asset and re-inline
if (this.incomingInlineRelation.from !== this) {
this.incomingInlineRelation.inline();
}
}
return this;
}
_vivifyRelation(outgoingRelation) {
outgoingRelation.from = this;
if (!outgoingRelation.isRelation) {
const originalHref = outgoingRelation.href;
if (typeof originalHref === 'string') {
outgoingRelation.to = { url: originalHref };
delete outgoingRelation.href;
}
const type = outgoingRelation.type;
delete outgoingRelation.type;
outgoingRelation = new AssetGraph[type](outgoingRelation);
// Make sure that we preserve the href of relations that maintain it as a direct property
// rather than a getter/setter:
if (typeof originalHref === 'string' && !('href' in outgoingRelation)) {
outgoingRelation.href = originalHref;
}
if (outgoingRelation.to && this.assetGraph) {
if (/^#/.test(outgoingRelation.to.url)) {
// Self-reference, only fragment
// Might need refinement for eg. url(#foo) in SvgStyle in inline Svg
outgoingRelation._to = this;
} else {
// Implicitly create the target asset:
outgoingRelation._to = this.assetGraph.addAsset(
outgoingRelation.to,
outgoingRelation
);
}
}
}
return outgoingRelation;
}
/**
* Get/set the outgoing relations of the asset.
*
* @type {Relation[]}
*/
get outgoingRelations() {
if (!this._outgoingRelations) {
this._outgoingRelations = this.findOutgoingRelationsInParseTree().map(
(outgoingRelation) => this._vivifyRelation(outgoingRelation)
);
}
return this._outgoingRelations;
}
set outgoingRelations(outgoingRelations) {
this._outgoingRelations = outgoingRelations.map((outgoingRelation) =>
this._vivifyRelation(outgoingRelation)
);
}
/**
* Attaches a Relation to the Asset.
*
* The ordering of certain relation types is significant
* (`HtmlScript`, for instance), so it's important that the order
* isn't scrambled in the indices. Therefore the caller must
* explicitly specify a position at which to insert the object.
*
* @param {Relation} relation The Relation to attach to the Asset
* @param {String} [position='last'] `"first"`, `"last"`, `"before"`, or `"after"`
* @param {Relation} [adjacentRelation] The adjacent relation, mandatory if the position is `"before"` or `"after"`
*/
addRelation(relation, position, adjacentRelation) {
relation = this._vivifyRelation(relation);
position = position || 'last';
if (typeof relation.node === 'undefined' && relation.attach) {
relation.attach(position, adjacentRelation);
} else {
relation.addToOutgoingRelations(position, adjacentRelation);
relation.refreshHref();
}
// Smells funny, could be moved to Asset#_vivifyRelation?
if (relation._hrefType === 'inline' || !relation.to.url) {
relation._hrefType = undefined;
relation.inline();
}
this.assetGraph.emit('addRelation', relation); // Consider getting rid of this
return relation;
}
/**
* Remove an outgoing Relation from the Asset by reference
*
* @param {Relation} relation Outgoing Relation
* @return {Asset} The Asset itself
*/
removeRelation(relation) {
if (this._outgoingRelations) {
const outgoingRelations = this.outgoingRelations;
const i = outgoingRelations.indexOf(relation);
if (i !== -1) {
outgoingRelations.splice(i, 1);
}
}
return this;
}
/**
* The subset of outgoing Relations that point to external (non-inlined) Assets
*
* @type {Relation[]}
*/
get externalRelations() {
const externalRelations = [];
const seenAssets = new Set();
(function gatherExternalRelations(asset) {
if (asset.keepUnpopulated || seenAssets.has(asset)) {
return;
}
seenAssets.add(asset);
for (const outgoingRelation of asset.outgoingRelations) {
if (outgoingRelation.to.isInline) {
gatherExternalRelations(outgoingRelation.to);
} else {
externalRelations.push(outgoingRelation);
}
}
})(this);
return externalRelations;
}
/**
* Parse the Asset for outgoing relations and return them.
*
* @return {Relation[]} The Assets outgoing Relations
*/
findOutgoingRelationsInParseTree() {
const outgoingRelations = [];
if (
typeof this.statusCode === 'number' &&
[301, 302, 303, 307, 308].includes(this.statusCode) &&
this.location !== undefined
) {
outgoingRelations.push({
type: 'HttpRedirect',
statusCode: this.statusCode,
href: this.location,
});
} else if (this.fileRedirectTargetUrl) {
outgoingRelations.push({
type: 'FileRedirect',
href: this.fileRedirectTargetUrl,
});
}
return outgoingRelations;
}
/**
* Get/set the relations pointing to this asset
*
* **Caveat**: Setting Does not remove/detach other relations already pointing at the
* asset, but not included in the array, so it's not strictly symmetric
* with the incomingRelations getter.
*
* @type {Relation[]}
*/
get incomingRelations() {
if (!this.assetGraph) {
throw new Error(
'Asset.incomingRelations getter: Asset is not part of an AssetGraph'
);
}
if (this.incomingInlineRelation) {
return [this.incomingInlineRelation];
} else {
return this.assetGraph.findRelations({ to: this });
}
}
set incomingRelations(incomingRelations) {
for (const relation of incomingRelations) {
relation.to = this;
}
}
_updateQueryString(obj) {
const url = this.url;
const queryString = qs.stringify(obj);
if (url) {
const urlObj = new urlModule.URL(url);
if (queryString.length > 0) {
urlObj.search = `?${queryString}`;
} else {
urlObj.search = '';
}
this.url = urlObj.toString();
} else {
this._query = queryString;
}
}
/**
* The query parameters part of the Assets URL.
*
* Can be set with a `String` or `Object`, but always returns `String` in the getters
*
* @type {String}
*/
get query() {
if (!this._query && !this.isInline) {
this._query = new Proxy(
qs.parse(new urlModule.URL(this.url).search.slice(1)),
{
get(target, parameterName) {
return target[parameterName];
},
set: (target, parameterName, value) => {
target[parameterName] = String(value);
this._updateQueryString(target);
},
deleteProperty: (target, parameterName) => {
delete target[parameterName];
this._updateQueryString(target);
},
}
);
}
return this._query;
}
set query(query) {
if (typeof query === 'string') {
query = qs.parse(query.replace(/^\?/, ''));
}
this._updateQueryString(query);
}
/**
* The username part of a URL `protocol://<username>:password@hostname:port/`
*
* @member {String} Asset#username
*/
/**
* The password part of a URL `protocol://username:<password>@hostname:port/`
*
* @member {String} Asset#password
*/
/**
* The hostname part of a URL `protocol://username:password@<hostname>:port/`
*
* @member {String} Asset#hostname
*/
/**
* The port part of a URL `protocol://username:password@hostname:<port>/`
*
* @member {Number} Asset#port
*/
/**
* The protocol part of a URL `<protocol:>//username:password@hostname:port/`.
* Includes trailing `:`
*
* @member {String} Asset#protocol
*/
/**
* Go through the outgoing relations of the asset and add the ones
* that refer to assets that are already part of the
* graph. Recurses into inline assets.
*
* You shouldn't need to call this manually.
*/
populate(force = false) {
if (!this.assetGraph) {
throw new Error('Asset.populate: Asset is not part of an AssetGraph');
}
if (
(force || this.isLoaded) &&
!this.keepUnpopulated &&
!this.isPopulated
) {
// eslint-disable-next-line no-unused-expressions
this.outgoingRelations; // For the side effects
this.isPopulated = true;
}
}
_unpopulate() {
if (this._outgoingRelations) {
// Remove inline assets and outgoing relations:
if (this.assetGraph && this.isPopulated) {
const outgoingRelations = [...this._outgoingRelations];
for (const outgoingRelation of outgoingRelations) {
if (outgoingRelation.hrefType === 'inline') {
// Remove inline asset
this.assetGraph.removeAsset(outgoingRelation.to);
}
}
}
this._outgoingRelations = undefined;
}
this.isPopulated = false;
}
/**
* Replace the asset in the graph with another asset, then remove
* it from the graph.
*
* Updates the incoming relations of the old asset to point at the
* new one and preserves the url of the old asset if it's not
* inline.
*
* @param {Asset} newAsset The asset to put replace this one with.
* @return {Asset} The new asset.
*/
replaceWith(newAsset) {
const thisUrl = this.url;
if (thisUrl && (!newAsset.url || newAsset.isAsset)) {
this._updateUrlIndex(undefined, thisUrl);
this._url = undefined;
newAsset.url = thisUrl;
}
newAsset = this.assetGraph.addAsset(newAsset);
for (const incomingRelation of this.incomingRelations) {
incomingRelation.to = newAsset;
}
if (!thisUrl && newAsset.url) {
newAsset.url = null;
}
this.assetGraph.removeAsset(this);
return newAsset;
}
/**
* Clone this asset instance and add the clone to the graph if
* this instance is part of a graph. As an extra service,
* optionally update some caller-specified relations to point at
* the clone.
*
* If this instance isn't inline, a url is made up for the clone.
*
* @param {Relation[]|Relation} incomingRelations (optional) Some incoming relations that should be pointed at the clone.
* @return {Asset} The cloned asset.
*/
clone(incomingRelations) {
if (incomingRelations && !this.assetGraph) {
throw new Error(
"asset.clone(): incomingRelations not supported because asset isn't in a graph"
);
}
// TODO: Clone more metadata
const constructorOptions = {
isInitial: this.isInitial,
_toBeMinified: this._toBeMinified,
isPretty: this.isPretty,
isDirty: this.isDirty,
extension: this.extension,
lastKnownByteLength: this.lastKnownByteLength,
serializationOptions: this.serializationOptions && {
...this.serializationOptions,
},
};
if (this.type === 'JavaScript' || this.type === 'Css') {
if (this._parseTree) {
constructorOptions.parseTree = this._cloneParseTree();
if (typeof this._text === 'string') {
constructorOptions.text = this._text;
}
if (this._rawSrc) {
constructorOptions.rawSrc = this._rawSrc;
}
} else {
const sourceMap = this.sourceMap;
if (sourceMap) {
constructorOptions.sourceMap = sourceMap;
}
}
} else if (this.isText) {
// Cheaper than encoding + decoding
constructorOptions.text = this.text;
} else {
constructorOptions.rawSrc = this.rawSrc;
}
if (this._isFragment !== undefined) {
// FIXME: Belongs in the subclass
constructorOptions._isFragment = this._isFragment;
}
if (this.type === 'JavaScript') {
// FIXME: Belongs in the subclass
if (this.initialComments) {
constructorOptions.initialComments = this.initialComments;
}
constructorOptions.isRequired = this.isRequired;
}
if (!this.isInline) {
let nextSuffixToTry = 0;
const baseName = this.baseName || this.id;
const extension = this.extension || this.defaultExtension;
do {
constructorOptions.url = this.assetGraph.resolveUrl(
this.url,
baseName + (nextSuffixToTry ? `-${nextSuffixToTry}` : '') + extension
);
nextSuffixToTry += 1;
} while (this.assetGraph._urlIndex[constructorOptions.url]);
}
const clone = new this.constructor(constructorOptions, this.assetGraph);
this.assetGraph.addAsset(clone);
if (!this.isInline) {
clone.externalize(this.url);
}
if (incomingRelations) {
if (!Array.isArray(incomingRelations)) {
incomingRelations = [incomingRelations];
}
for (const incomingRelation of incomingRelations) {
if (!incomingRelation || !incomingRelation.isRelation) {
throw new Error(
`asset.clone(): Incoming relation is not a relation: ${incomingRelation.toString()}`
);
}
incomingRelation.to = clone;
}
}
return clone;
}
/**
* Get a brief text containing the type, id, and url (if not inline) of the asset.
*
* @return {String} The string, eg. "[JavaScript/141 file:///the/thing.js]"
*/
toString() {
return `[${this.type}/${this.id} ${this.urlOrDescription}]`;
}
/**
* A Human readable URL or Asset description if inline.
* Paths for `file://` URL's are kept relative to the current
* working directory for easier copy/paste if needed.
*
* @type {String}
*/
get urlOrDescription() {
function makeRelativeToCwdIfPossible(url) {
if (/^file:\/\//.test(url)) {
try {
return pathModule.relative(
process.cwd(),
urlTools.fileUrlToFsPath(url)
);
} catch (err) {
err.message = `Error while attempting to resolve working directory relative path for ${url}: ${err.message}`;
this.assetGraph.emit('warn', err);
return url;
}
} else {
return url;
}
}
return this.url
? makeRelativeToCwdIfPossible(this.url)
: `inline ${this.type}${
this.nonInlineAncestor
? ` in ${makeRelativeToCwdIfPossible(this.nonInlineAncestor.url)}`
: ''
}`;
}
}
for (const urlPropertyName of [
'username',
'password',
'hostname',
'port',
'protocol',
]) {
Object.defineProperty(Asset.prototype, urlPropertyName, {
get() {
const currentValue = this[`_${urlPropertyName}`];
if (typeof currentValue !== 'undefined') {
return currentValue;
}
const url = this.url;
if (url) {
let value = new urlModule.URL(url)[urlPropertyName];
if (urlPropertyName === 'port') {
if (value) {
value = parseInt(value, 10);
} else {
value = undefined;
}
}
return value;
}
},
set(value) {
const url = this.url;
if (url) {
const urlObj = new urlModule.URL(url);
urlObj[urlPropertyName] = value;
this.url = urlObj.toString();
} else if (this.isInline) {
throw new Error(
`Cannot update the ${urlPropertyName} of an inline asset`
);
}
},
});
}
Object.assign(Asset.prototype, {
/**
* Boolean Property that's true for all Asset instances. Avoids
* reliance on the `instanceof` operator.
*
* @constant
* @type {Boolean}
* @memberOf Asset#
* @default true
*/
isAsset: true,
isResolved: true,
isRedirect: false,
/**
* Whether the asset occurs in a context where it can be
* made external. If false, the asset will stay inline. Useful for
* "always inline" assets pointed to by {@link HtmlConditionalComment},
* {@link HtmlDataBindAttribute}, and {@link HtmlKnockoutContainerless}
* relations. Override when creating the asset.
*
* @type {Boolean}
* @memberOf Asset#
* @default true
*/
isExternalizable: true,
/**
* The Content-Type (MIME type) of the asset.
*
* @type {String}
* @memberOf Asset#
* @default 'appliction/octet-stream'
*/
contentType: 'application/octet-stream',
});
module.exports = Asset;