UNPKG

assetgraph

Version:

An auto discovery dependency graph based optimization framework for web pages and applications

678 lines (631 loc) 21.7 kB
/* eslint no-redeclare: "off", block-scoped-var: "off" */ const _ = require('lodash'); const escodegen = require('escodegen'); const estraverse = require('estraverse-fb'); const espurify = require('espurify'); const mozilla = require('source-map'); const repeatString = require('repeat-string'); const errors = require('../errors'); const Text = require('./Text'); const parseJavaScript = require('../parseJavaScript'); const { hrefTypes, getHrefType } = require('../util/getHrefType'); // Avoid modelling "bare" imports as relations (import { uniq } from 'lodash') function isImportUrl(href) { return /^\.{0,2}\/|^[a-z0-9+]+:/.test(href); } class JavaScript extends Text { init(config) { super.init(config); this.serializationOptions = this.serializationOptions || {}; // Boolean indicating if the asset has a global "use strict"; statement // Useful for wrapping the asset in an IIFE when concatenating in order to avoid leakage this.strict = false; } _getEscodegenOptions() { const outputOptions = _.defaults( {}, this.serializationOptions, this.assetGraph && this.assetGraph.javaScriptSerializationOptions ); return { format: { quotes: 'auto', compact: outputOptions.compact, // Takes precedence over space, newline, and indent space: typeof outputOptions.space === 'undefined' ? this.isPretty || !this._toBeMinified ? ' ' : '' : outputOptions.space, newline: typeof outputOptions.newline === 'undefined' ? this.isPretty || !this._toBeMinified ? '\n' : '' : outputOptions.newline, semicolons: typeof outputOptions.semicolons === 'undefined' ? this.isPretty || !this._toBeMinified : outputOptions.semicolons, parentheses: typeof outputOptions.parentheses === 'undefined' ? this.isPretty || !this._toBeMinified : outputOptions.parentheses, escapeless: typeof outputOptions.escapeless === 'undefined' ? !outputOptions.ascii_only : outputOptions.escapeless, indent: { style: typeof outputOptions.indent_level === 'number' ? repeatString(' ', outputOptions.indent_level) : this.isPretty || !this._toBeMinified ? ' ' : '' } }, comment: true, attachComment: true }; } get text() { if (typeof this._text !== 'string') { const parseTree = this._parseTree; if (parseTree) { this._text = this._patchUpSerializedText( escodegen.generate(parseTree, this._getEscodegenOptions()), parseTree ); } else { this._text = this._getTextFromRawSrc(); } } return this._text; } set text(text) { super.text = text; } get sourceMap() { if (typeof this._sourceMap === 'undefined') { this._sourceMap = escodegen.generate( this.parseTree, _.defaults({ sourceMap: true }, this._getEscodegenOptions()) ); } if (typeof this._sourceMap === 'string') { this._sourceMap = JSON.parse(this._sourceMap); } return this._sourceMap; } _applySourceMapToParseTree() { const sourceMapConsumer = new mozilla.SourceMapConsumer(this._sourceMap); try { estraverse.traverse(this.parseTree, { enter(node) { if (!(node.type && node.loc)) { return; } const origStart = sourceMapConsumer.originalPositionFor( node.loc.start ); if (!origStart.line) { delete node.loc; return; } const origEnd = sourceMapConsumer.originalPositionFor(node.loc.end); if ( origEnd.line && (origEnd.line < origStart.line || origEnd.column < origStart.column) ) { origEnd.line = null; } node.loc = { start: { line: origStart.line, column: origStart.column }, end: origEnd.line && { line: origEnd.line, column: origEnd.column }, source: origStart.source, name: origStart.name }; } }); } catch (e) { // FIXME: Look into why this fails on some of the assetgraph-builder tests: if (!/^Line must be greater than/.test(e.message)) { throw e; } } } set sourceMap(sourceMap) { this._sourceMap = sourceMap; if (typeof this._parseTree !== 'undefined') { this._applySourceMapToParseTree(); } } _patchUpSerializedText(text, parseTree) { if (text.length > 0) { if (this.isInline) { // Make sure that '</script>' does not occur inside the script text = text.replace(/<\/(?=(\s*)script[/ >])/gi, '<\\/'); // Trim trailing whitespace that might occur due to // escodegen.generate(ast, {format: { newline: '\n' } }); text = text.replace(/\s+$/, ''); } else { const lastStatement = parseTree.body.length && parseTree.body[parseTree.body.length - 1]; if ( lastStatement.trailingComments && lastStatement.trailingComments.length > 0 ) { text = text.replace(/\n?$/, '\n'); // End with a newline if the asset ends with a comment } else { text = text.replace(/;*$/, ';'); // Always end with a semicolon like the UglifyJS binary (if non-empty) } } } return text; } get textAndSourceMap() { if (typeof this._text === 'string') { return { text: this._text, sourceMap: this.sourceMap }; } else if ( typeof this._parseTree !== 'undefined' && typeof this._sourceMap !== 'undefined' ) { return { text: this.text, sourceMap: this.sourceMap }; } else { const parseTree = this._parseTree; const result = escodegen.generate( parseTree, _.defaults( { sourceMap: true, sourceMapWithCode: true }, this._getEscodegenOptions() ) ); this._text = this._patchUpSerializedText(result.code, parseTree); this._sourceMap = result.map.toJSON(); return { text: this._text, sourceMap: this.sourceMap }; } } _parse(text, sourceType = 'module') { const nonInlineAncestor = this.nonInlineAncestor; const sourceFile = this.sourceUrl || (nonInlineAncestor && nonInlineAncestor.url) || this.url || `standalone-${this.id}.js`; this._parseTree = parseJavaScript(text, { locations: true, tokens: true, ranges: true, ecmaVersion: 11, sourceFile, sourceType }); if (this._sourceMap) { this._applySourceMapToParseTree(); } } get parseTree() { if (!this._parseTree) { const text = this.text; try { this._parse(text); } catch (errorParsingAsModule) { let parsedAsScript = false; try { this._parse(text, 'script'); parsedAsScript = true; } catch (errorParsingAsScript) { const message = `Parse error in ${this.urlOrDescription}\n${errorParsingAsScript.message}`; const err = new errors.ParseError({ message, line: errorParsingAsScript.lineNumber, index: errorParsingAsScript.index, asset: this }); if (this.assetGraph) { this.assetGraph.warn(err); } else { throw err; } this._parse(''); } if (this.assetGraph && parsedAsScript) { this.assetGraph.info( new errors.ParseError( `Could not parse ${this.urlOrDescription} as a module, fall back to script mode\n${errorParsingAsModule.message}` ) ); } } } return this._parseTree; } set parseTree(parseTree) { this.unload(); this._parseTree = parseTree; this._sourceMap = undefined; this._lastKnownByteLength = undefined; if (this.assetGraph) { this.populate(); } this.markDirty(); } get isEmpty() { return this.parseTree.body.length === 0; } _cloneParseTree() { return espurify.customize({ extra: [ 'loc', 'range', 'source_type', 'comments', 'raw', 'trailingComments', 'leadingComments' ] })(this._parseTree); } minify() { this.isPretty = false; this._toBeMinified = true; const parseTree = this.parseTree; // Important side effect: The markDirty call below will remove this._text function filterCommentAttribute(node, attributeName) { if (node[attributeName]) { const leftoverComments = node[attributeName].filter( JavaScript.shouldCommentNodeBePreservedInNonPrettyPrintedOutput ); if (leftoverComments.length > 0) { node[attributeName] = leftoverComments; } else { node[attributeName] = undefined; } } } estraverse.traverse(parseTree, { enter(node) { filterCommentAttribute(node, 'leadingComments'); filterCommentAttribute(node, 'trailingComments'); } }); this.markDirty(); return this; } prettyPrint() { this.isPretty = true; this._toBeMinified = false; this.markDirty(); return this; } findOutgoingRelationsInParseTree() { const outgoingRelations = super.findOutgoingRelationsInParseTree(); if (!this.isLoaded) { return outgoingRelations; } const syntaxErrors = []; const warnings = []; const infos = []; const that = this; const parseTree = this.parseTree; const processedComments = []; estraverse.traverse(parseTree, { enter(node, parentNode) { for (const propertyName of ['leadingComments', 'trailingComments']) { for (const comment of node[propertyName] || []) { if (!processedComments.includes(comment)) { processedComments.push(comment); const matchSourceUrlOrSourceMappingUrl = comment.value.match( /[@#]\s*source(Mapping)?URL=([^\s\n]*)/ ); if (matchSourceUrlOrSourceMappingUrl) { if (matchSourceUrlOrSourceMappingUrl[1] === 'Mapping') { outgoingRelations.push({ type: 'JavaScriptSourceMappingUrl', node: comment, parentNode: node, commentPropertyName: propertyName, to: { url: matchSourceUrlOrSourceMappingUrl[2], // Source maps are currently served as application/json, so prevent the target asset // from being loaded as a Json asset: type: 'SourceMap' } }); } else { outgoingRelations.push({ type: 'JavaScriptSourceUrl', node: comment, parentNode: node, commentPropertyName: propertyName, href: matchSourceUrlOrSourceMappingUrl[2] }); } } else { const matchSystemJsBundle = comment.value.match( /#\s*SystemJsBundle=([^\s\n]*)/ ); if (matchSystemJsBundle) { outgoingRelations.push({ type: 'SystemJsBundle', node: comment, parentNode: node, commentPropertyName: propertyName, href: matchSystemJsBundle[1] }); } } } } } const stack = this.parents(); stack.push(node); // Detect global 'use strict' directives if ( parentNode === that.parseTree && node.type === 'ExpressionStatement' && node.expression.type === 'Literal' && node.expression.value === 'use strict' ) { that.strict = true; } if ( node.type === 'ImportExpression' && node.source.type === 'Literal' && typeof node.source.value === 'string' ) { if (isImportUrl(node.source.value)) { outgoingRelations.push({ type: 'JavaScriptDynamicImport', node, href: node.source.value }); } } else if (node.type === 'CallExpression') { const parentParentNode = stack[stack.length - 3]; if ( node.callee.type === 'MemberExpression' && !node.callee.computed && node.callee.property.type === 'Identifier' && node.callee.property.name === 'toString' && node.callee.object.type === 'Literal' && typeof node.callee.object.value === 'string' && node.arguments.length === 1 && node.arguments[0].type === 'Literal' && node.arguments[0].value === 'url' ) { outgoingRelations.push({ type: 'JavaScriptStaticUrl', node, argumentNode: node.callee.object, parentNode, href: node.callee.object.value }); } else if ( node.callee.type === 'MemberExpression' && node.callee.object.type === 'MemberExpression' && node.callee.object.object.name === 'navigator' && node.callee.object.property.name === 'serviceWorker' && node.callee.property.name === 'register' && typeof node.arguments[0].value === 'string' ) { // Service worker registration // navigator.serviceWorker.register('sw.js') // Scope detection // if (node.arguments[1] && // node.arguments[1].type === 'ObjectExpression' // ) { // const scope = that.assetGraph.root; // node.arguments[1].properties.some(function (property) { // if (property.key.name === 'scope') { // scope = property.value.value; // return true; // } // }); // } outgoingRelations.push({ type: 'JavaScriptServiceWorkerRegistration', href: node.arguments[0].value, node }); } // Javasscript paint worklet: https://developer.mozilla.org/en-US/docs/Web/API/PaintWorklet#Load_a_PaintWorklet // CSS.paintWorklet.addModule('/rootRelativeHref'); // CSS.layoutWorklet.addModule('masonry.js'); // CSS.animationWorklet.addModule('spring-sticky-animator.js') // let context = new AudioContext(); context.audioWorklet.addModule('processors.js') if ( node.type === 'CallExpression' && node.callee.object && node.callee.object.object && node.callee.object.object.type === 'Identifier' && node.callee.object.property && node.callee.object.property.type === 'Identifier' && [ 'paintWorklet', 'layoutWorklet', 'animationWorklet', 'audioWorklet' ].includes(node.callee.object.property.name) && node.callee.property && node.callee.property.type === 'Identifier' && node.callee.property.name === 'addModule' && node.arguments[0] && node.arguments[0].type === 'Literal' ) { if ( node.callee.object.object.name === 'CSS' || node.callee.object.property.name === 'audioWorklet' ) { const hrefNode = node.arguments[0]; const href = hrefNode.value; outgoingRelations.push({ type: 'JavaScriptWorklet', node: hrefNode, parentNode: node, href: href }); if (getHrefType(href) === hrefTypes.RELATIVE) { const err = new Error( `Using a relative URL when adding a worklet can cause problems. The href is resolved relative to the page's url, not the javascript module that include it` ); err.assset = this; err.line = hrefNode.loc.start.line; err.column = hrefNode.loc.start.column; warnings.push(err); } } } // JavaScript Fetch if ( ((!that.assetGraph || !that.assetGraph.disableFetch) && // fetch('foo') (node.callee.type === 'Identifier' && node.callee.name === 'fetch')) || // window.fetch('foo') (node.callee.type === 'MemberExpression' && !node.callee.computed && node.callee.property.type === 'Identifier' && node.callee.property.name === 'fetch' && node.callee.object.type === 'Identifier' && node.callee.object.name === 'window') ) { // First argument must be a string literal if ( node.arguments[0] && node.arguments[0].type === 'Literal' && typeof node.arguments[0].value === 'string' ) { outgoingRelations.push({ type: 'JavaScriptFetch', detachableNode: parentNode.type === 'SequenceExpression' ? node : parentNode, parentNode: parentNode.type === 'SequenceExpression' ? parentNode : parentParentNode, node: node.arguments[0], href: node.arguments[0].value }); } } else if ( (node.callee.type === 'Identifier' && node.callee.name === 'importScripts') || (node.callee.type === 'MemberExpression' && !node.callee.computed && node.callee.property.type === 'Identifier' && node.callee.property.name === 'importScripts' && node.callee.object.type === 'Identifier' && node.callee.object.name === 'self') ) { for (const argumentNode of node.arguments) { if ( argumentNode.type === 'Literal' && typeof argumentNode.value === 'string' ) { outgoingRelations.push({ type: 'JavaScriptImportScripts', detachableNode: parentNode.type === 'SequenceExpression' ? node : parentNode, parentNode: parentNode.type === 'SequenceExpression' ? parentNode : parentParentNode, argumentsNode: node.arguments, node: argumentNode, href: argumentNode.value }); } } } } else if ( node.type === 'NewExpression' && node.callee.type === 'Identifier' && node.callee.name === 'Worker' && node.arguments.length === 1 && node.arguments[0].type === 'Literal' && typeof node.arguments[0].value === 'string' ) { outgoingRelations.push({ type: 'JavaScriptWebWorker', node, href: node.arguments[0].value }); } else if (node.type === 'ImportDeclaration') { if (isImportUrl(node.source.value)) { outgoingRelations.push({ type: 'JavaScriptImport', node, href: node.source.value }); } } else if ( (node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') && node.source ) { if (isImportUrl(node.source.value)) { outgoingRelations.push({ type: 'JavaScriptExport', node, href: node.source.value }); } } } }); if (syntaxErrors.length > 0) { if (this.assetGraph) { for (const syntaxError of syntaxErrors) { syntaxError.asset = this; this.assetGraph.warn(syntaxError); } } else { throw syntaxErrors[0]; } } for (const warning of warnings) { if (this.assetGraph) { warning.asset = this; this.assetGraph.warn(warning); } else { console.warn(warning.message); } } for (const info of infos) { if (this.assetGraph) { info.asset = this; this.assetGraph.info(info); } else { console.info(info.message); } } return outgoingRelations; } } Object.assign(JavaScript.prototype, { contentType: 'application/javascript', supportedExtensions: ['.js'] }); JavaScript.shouldCommentNodeBePreservedInNonPrettyPrintedOutput = comment => { return /@jsx|@preserve|@license|@cc_on|#\s*SystemJsBundle|[@#]\s*sourceURL|[#@]\s*sourceMappingURL|^!/.test( comment.value ); }; module.exports = JavaScript;