src/compiler-host.js
import _ from 'lodash';
import mimeTypes from 'mime-types';
import fs from 'fs';
import zlib from 'zlib';
import path from 'path';
import {pfs, pzlib} from './promise';
import {forAllFiles, forAllFilesSync} from './for-all-files';
import CompileCache from './compile-cache';
import FileChangedCache from './file-change-cache';
import ReadOnlyCompiler from './read-only-compiler';
const d = require('debug')('electron-compile:compiler-host');
// This isn't even my
const finalForms = {
'text/javascript': true,
'application/javascript': true,
'text/html': true,
'text/css': true,
'image/svg+xml': true
};
/**
* This class is the top-level class that encapsulates all of the logic of
* compiling and caching application code. If you're looking for a "Main class",
* this is it.
*
* This class can be created directly but it is usually created via the methods
* in config-parser, which will among other things, set up the compiler options
* given a project root.
*
* CompilerHost is also the top-level class that knows how to serialize all of the
* information necessary to recreate itself, either as a development host (i.e.
* will allow cache misses and actual compilation), or as a read-only version of
* itself for production.
*/
export default class CompilerHost {
/**
* Creates an instance of CompilerHost. You probably want to use the methods
* in config-parser for development, or {@link createReadonlyFromConfiguration}
* for production instead.
*
* @param {string} rootCacheDir The root directory to use for the cache
*
* @param {Object} compilers an Object whose keys are input MIME types and
* whose values are instances of CompilerBase. Create
* this via the {@link createCompilers} method in
* config-parser.
*
* @param {FileChangedCache} fileChangeCache A file-change cache that is
* optionally pre-loaded.
*
* @param {boolean} readOnlyMode If True, cache misses will fail and
* compilation will not be attempted.
*
* @param {CompilerBase} fallbackCompiler (optional) When a file is compiled
* which doesn't have a matching compiler,
* this compiler will be used instead. If
* null, will fail compilation. A good
* alternate fallback is the compiler for
* 'text/plain', which is guaranteed to be
* present.
*/
constructor(rootCacheDir, compilers, fileChangeCache, readOnlyMode, fallbackCompiler = null) {
let compilersByMimeType = _.assign({}, compilers);
_.assign(this, {rootCacheDir, compilersByMimeType, fileChangeCache, readOnlyMode, fallbackCompiler});
this.appRoot = this.fileChangeCache.appRoot;
this.cachesForCompilers = _.reduce(Object.keys(compilersByMimeType), (acc, x) => {
let compiler = compilersByMimeType[x];
if (acc.has(compiler)) return acc;
acc.set(compiler, CompileCache.createFromCompiler(rootCacheDir, compiler, fileChangeCache));
return acc;
}, new Map());
}
/**
* Creates a production-mode CompilerHost from the previously saved
* configuration
*
* @param {string} rootCacheDir The root directory to use for the cache. This
* cache must have cache information saved via
* {@link saveConfiguration}
*
* @param {string} appRoot The top-level directory for your application (i.e.
* the one which has your package.json).
*
* @param {CompilerBase} fallbackCompiler (optional) When a file is compiled
* which doesn't have a matching compiler,
* this compiler will be used instead. If
* null, will fail compilation. A good
* alternate fallback is the compiler for
* 'text/plain', which is guaranteed to be
* present.
*
* @return {Promise<CompilerHost>} A read-only CompilerHost
*/
static async createReadonlyFromConfiguration(rootCacheDir, appRoot, fallbackCompiler=null) {
let target = path.join(rootCacheDir, 'compiler-info.json.gz');
let buf = await pfs.readFile(target);
let info = JSON.parse(await pzlib.gunzip(buf));
let fileChangeCache = FileChangedCache.loadFromData(info.fileChangeCache, appRoot, true);
let compilers = _.reduce(Object.keys(info.compilers), (acc, x) => {
let cur = info.compilers[x];
acc[x] = new ReadOnlyCompiler(cur.name, cur.compilerVersion, cur.compilerOptions, cur.inputMimeTypes);
return acc;
}, {});
return new CompilerHost(rootCacheDir, compilers, fileChangeCache, true, fallbackCompiler);
}
/**
* Creates a development-mode CompilerHost from the previously saved
* configuration.
*
* @param {string} rootCacheDir The root directory to use for the cache. This
* cache must have cache information saved via
* {@link saveConfiguration}
*
* @param {string} appRoot The top-level directory for your application (i.e.
* the one which has your package.json).
*
* @param {Object} compilersByMimeType an Object whose keys are input MIME
* types and whose values are instances
* of CompilerBase. Create this via the
* {@link createCompilers} method in
* config-parser.
*
* @param {CompilerBase} fallbackCompiler (optional) When a file is compiled
* which doesn't have a matching compiler,
* this compiler will be used instead. If
* null, will fail compilation. A good
* alternate fallback is the compiler for
* 'text/plain', which is guaranteed to be
* present.
*
* @return {Promise<CompilerHost>} A read-only CompilerHost
*/
static async createFromConfiguration(rootCacheDir, appRoot, compilersByMimeType, fallbackCompiler=null) {
let target = path.join(rootCacheDir, 'compiler-info.json.gz');
let buf = await pfs.readFile(target);
let info = JSON.parse(await pzlib.gunzip(buf));
let fileChangeCache = FileChangedCache.loadFromData(info.fileChangeCache, appRoot, false);
_.each(Object.keys(info.compilers), (x) => {
let cur = info.compilers[x];
compilersByMimeType[x].compilerOptions = cur.compilerOptions;
});
return new CompilerHost(rootCacheDir, compilersByMimeType, fileChangeCache, false, fallbackCompiler);
}
/**
* Saves the current compiler configuration to a file that
* {@link createReadonlyFromConfiguration} can use to recreate the current
* compiler environment
*
* @return {Promise} Completion
*/
async saveConfiguration() {
let serializedCompilerOpts = _.reduce(Object.keys(this.compilersByMimeType), (acc, x) => {
let compiler = this.compilersByMimeType[x];
let Klass = Object.getPrototypeOf(compiler).constructor;
let val = {
name: Klass.name,
inputMimeTypes: Klass.getInputMimeTypes(),
compilerOptions: compiler.compilerOptions,
compilerVersion: compiler.getCompilerVersion()
};
acc[x] = val;
return acc;
}, {});
let info = {
fileChangeCache: this.fileChangeCache.getSavedData(),
compilers: serializedCompilerOpts
};
let target = path.join(this.rootCacheDir, 'compiler-info.json.gz');
let buf = await pzlib.gzip(new Buffer(JSON.stringify(info)));
await pfs.writeFile(target, buf);
}
/**
* Compiles a file and returns the compiled result.
*
* @param {string} filePath The path to the file to compile
*
* @return {Promise<object>} An Object with the compiled result
*
* @property {Object} hashInfo The hash information returned from getHashForPath
* @property {string} code The source code if the file was a text file
* @property {Buffer} binaryData The file if it was a binary file
* @property {string} mimeType The MIME type saved in the cache.
* @property {string[]} dependentFiles The dependent files returned from
* compiling the file, if any.
*/
compile(filePath) {
return (this.readOnlyMode ? this.compileReadOnly(filePath) : this.fullCompile(filePath));
}
/**
* Handles compilation in read-only mode
*
* @private
*/
async compileReadOnly(filePath) {
// We guarantee that node_modules are always shipped directly
let type = mimeTypes.lookup(filePath);
if (FileChangedCache.isInNodeModules(filePath)) {
return {
mimeType: type || 'application/javascript',
code: await pfs.readFile(filePath, 'utf8')
};
}
let hashInfo = await this.fileChangeCache.getHashForPath(filePath);
// NB: Here, we're basically only using the compiler here to find
// the appropriate CompileCache
let compiler = CompilerHost.shouldPassthrough(hashInfo) ?
this.getPassthroughCompiler() :
this.compilersByMimeType[type || '__lolnothere'];
if (!compiler) compiler = this.fallbackCompiler;
let cache = this.cachesForCompilers.get(compiler);
let {code, binaryData, mimeType} = await cache.get(filePath);
code = code || binaryData;
if (!code || !mimeType) {
throw new Error(`Asked to compile ${filePath} in production, is this file not precompiled?`);
}
return { code, mimeType };
}
/**
* Handles compilation in read-write mode
*
* @private
*/
async fullCompile(filePath) {
d(`Compiling ${filePath}`);
let hashInfo = await this.fileChangeCache.getHashForPath(filePath);
let type = mimeTypes.lookup(filePath);
if (hashInfo.isInNodeModules) {
let code = hashInfo.sourceCode || await pfs.readFile(filePath, 'utf8');
return { code, mimeType: type };
}
let compiler = CompilerHost.shouldPassthrough(hashInfo) ?
this.getPassthroughCompiler() :
this.compilersByMimeType[type || '__lolnothere'];
if (!compiler) {
d(`Falling back to passthrough compiler for ${filePath}`);
compiler = this.fallbackCompiler;
}
if (!compiler) {
throw new Error(`Couldn't find a compiler for ${filePath}`);
}
let cache = this.cachesForCompilers.get(compiler);
return await cache.getOrFetch(
filePath,
(filePath, hashInfo) => this.compileUncached(filePath, hashInfo, compiler));
}
/**
* Handles invoking compilers independent of caching
*
* @private
*/
async compileUncached(filePath, hashInfo, compiler) {
let inputMimeType = mimeTypes.lookup(filePath);
if (hashInfo.isFileBinary) {
return {
binaryData: hashInfo.binaryData || await pfs.readFile(filePath),
mimeType: inputMimeType,
dependentFiles: []
};
}
let ctx = {};
let code = hashInfo.sourceCode || await pfs.readFile(filePath, 'utf8');
if (!(await compiler.shouldCompileFile(code, ctx))) {
d(`Compiler returned false for shouldCompileFile: ${filePath}`);
return { code, mimeType: mimeTypes.lookup(filePath), dependentFiles: [] };
}
let dependentFiles = await compiler.determineDependentFiles(code, filePath, ctx);
d(`Using compiler options: ${JSON.stringify(compiler.compilerOptions)}`);
let result = await compiler.compile(code, filePath, ctx);
let shouldInlineHtmlify =
inputMimeType !== 'text/html' &&
result.mimeType === 'text/html';
let isPassthrough =
result.mimeType === 'text/plain' ||
!result.mimeType ||
CompilerHost.shouldPassthrough(hashInfo);
if ((finalForms[result.mimeType] && !shouldInlineHtmlify) || isPassthrough) {
// Got something we can use in-browser, let's return it
return _.assign(result, {dependentFiles});
} else {
d(`Recursively compiling result of ${filePath} with non-final MIME type ${result.mimeType}, input was ${inputMimeType}`);
hashInfo = _.assign({ sourceCode: result.code, mimeType: result.mimeType }, hashInfo);
compiler = this.compilersByMimeType[result.mimeType || '__lolnothere'];
if (!compiler) {
d(`Recursive compile failed - intermediate result: ${JSON.stringify(result)}`);
throw new Error(`Compiling ${filePath} resulted in a MIME type of ${result.mimeType}, which we don't know how to handle`);
}
return await this.compileUncached(
`${filePath}.${mimeTypes.extension(result.mimeType || 'txt')}`,
hashInfo, compiler);
}
}
/**
* Pre-caches an entire directory of files recursively. Usually used for
* building custom compiler tooling.
*
* @param {string} rootDirectory The top-level directory to compile
*
* @param {Function} shouldCompile (optional) A Function which allows the
* caller to disable compiling certain files.
* It takes a fully-qualified path to a file,
* and should return a Boolean.
*
* @return {Promise} Completion.
*/
async compileAll(rootDirectory, shouldCompile=null) {
let should = shouldCompile || function() {return true;};
await forAllFiles(rootDirectory, (f) => {
if (!should(f)) return;
d(`Compiling ${f}`);
return this.compile(f, this.compilersByMimeType);
});
}
/*
* Sync Methods
*/
compileSync(filePath) {
return (this.readOnlyMode ? this.compileReadOnlySync(filePath) : this.fullCompileSync(filePath));
}
static createReadonlyFromConfigurationSync(rootCacheDir, appRoot, fallbackCompiler=null) {
let target = path.join(rootCacheDir, 'compiler-info.json.gz');
let buf = fs.readFileSync(target);
let info = JSON.parse(zlib.gunzipSync(buf));
let fileChangeCache = FileChangedCache.loadFromData(info.fileChangeCache, appRoot, true);
let compilers = _.reduce(Object.keys(info.compilers), (acc, x) => {
let cur = info.compilers[x];
acc[x] = new ReadOnlyCompiler(cur.name, cur.compilerVersion, cur.compilerOptions, cur.inputMimeTypes);
return acc;
}, {});
return new CompilerHost(rootCacheDir, compilers, fileChangeCache, true, fallbackCompiler);
}
static createFromConfigurationSync(rootCacheDir, appRoot, compilersByMimeType, fallbackCompiler=null) {
let target = path.join(rootCacheDir, 'compiler-info.json.gz');
let buf = fs.readFileSync(target);
let info = JSON.parse(zlib.gunzipSync(buf));
let fileChangeCache = FileChangedCache.loadFromData(info.fileChangeCache, appRoot, false);
_.each(Object.keys(info.compilers), (x) => {
let cur = info.compilers[x];
compilersByMimeType[x].compilerOptions = cur.compilerOptions;
});
return new CompilerHost(rootCacheDir, compilersByMimeType, fileChangeCache, false, fallbackCompiler);
}
saveConfigurationSync() {
let serializedCompilerOpts = _.reduce(Object.keys(this.compilersByMimeType), (acc, x) => {
let compiler = this.compilersByMimeType[x];
let Klass = Object.getPrototypeOf(compiler).constructor;
let val = {
name: Klass.name,
inputMimeTypes: Klass.getInputMimeTypes(),
compilerOptions: compiler.compilerOptions,
compilerVersion: compiler.getCompilerVersion()
};
acc[x] = val;
return acc;
}, {});
let info = {
fileChangeCache: this.fileChangeCache.getSavedData(),
compilers: serializedCompilerOpts
};
let target = path.join(this.rootCacheDir, 'compiler-info.json.gz');
let buf = zlib.gzipSync(new Buffer(JSON.stringify(info)));
fs.writeFileSync(target, buf);
}
compileReadOnlySync(filePath) {
// We guarantee that node_modules are always shipped directly
let type = mimeTypes.lookup(filePath);
if (FileChangedCache.isInNodeModules(filePath)) {
return {
mimeType: type || 'application/javascript',
code: fs.readFileSync(filePath, 'utf8')
};
}
let hashInfo = this.fileChangeCache.getHashForPathSync(filePath);
// We guarantee that node_modules are always shipped directly
if (hashInfo.isInNodeModules) {
return {
mimeType: type,
code: hashInfo.sourceCode || fs.readFileSync(filePath, 'utf8')
};
}
// NB: Here, we're basically only using the compiler here to find
// the appropriate CompileCache
let compiler = CompilerHost.shouldPassthrough(hashInfo) ?
this.getPassthroughCompiler() :
this.compilersByMimeType[type || '__lolnothere'];
if (!compiler) compiler = this.fallbackCompiler;
let cache = this.cachesForCompilers.get(compiler);
let {code, binaryData, mimeType} = cache.getSync(filePath);
code = code || binaryData;
if (!code || !mimeType) {
throw new Error(`Asked to compile ${filePath} in production, is this file not precompiled?`);
}
return { code, mimeType };
}
fullCompileSync(filePath) {
d(`Compiling ${filePath}`);
let hashInfo = this.fileChangeCache.getHashForPathSync(filePath);
let type = mimeTypes.lookup(filePath);
if (hashInfo.isInNodeModules) {
let code = hashInfo.sourceCode || fs.readFileSync(filePath, 'utf8');
return { code, mimeType: type };
}
let compiler = CompilerHost.shouldPassthrough(hashInfo) ?
this.getPassthroughCompiler() :
this.compilersByMimeType[type || '__lolnothere'];
if (!compiler) {
d(`Falling back to passthrough compiler for ${filePath}`);
compiler = this.fallbackCompiler;
}
if (!compiler) {
throw new Error(`Couldn't find a compiler for ${filePath}`);
}
let cache = this.cachesForCompilers.get(compiler);
return cache.getOrFetchSync(
filePath,
(filePath, hashInfo) => this.compileUncachedSync(filePath, hashInfo, compiler));
}
compileUncachedSync(filePath, hashInfo, compiler) {
let inputMimeType = mimeTypes.lookup(filePath);
if (hashInfo.isFileBinary) {
return {
binaryData: hashInfo.binaryData || fs.readFileSync(filePath),
mimeType: inputMimeType,
dependentFiles: []
};
}
let ctx = {};
let code = hashInfo.sourceCode || fs.readFileSync(filePath, 'utf8');
if (!(compiler.shouldCompileFileSync(code, ctx))) {
d(`Compiler returned false for shouldCompileFile: ${filePath}`);
return { code, mimeType: mimeTypes.lookup(filePath), dependentFiles: [] };
}
let dependentFiles = compiler.determineDependentFilesSync(code, filePath, ctx);
let result = compiler.compileSync(code, filePath, ctx);
let shouldInlineHtmlify =
inputMimeType !== 'text/html' &&
result.mimeType === 'text/html';
let isPassthrough =
result.mimeType === 'text/plain' ||
!result.mimeType ||
CompilerHost.shouldPassthrough(hashInfo);
if ((finalForms[result.mimeType] && !shouldInlineHtmlify) || isPassthrough) {
// Got something we can use in-browser, let's return it
return _.assign(result, {dependentFiles});
} else {
d(`Recursively compiling result of ${filePath} with non-final MIME type ${result.mimeType}, input was ${inputMimeType}`);
hashInfo = _.assign({ sourceCode: result.code, mimeType: result.mimeType }, hashInfo);
compiler = this.compilersByMimeType[result.mimeType || '__lolnothere'];
if (!compiler) {
d(`Recursive compile failed - intermediate result: ${JSON.stringify(result)}`);
throw new Error(`Compiling ${filePath} resulted in a MIME type of ${result.mimeType}, which we don't know how to handle`);
}
return this.compileUncachedSync(
`${filePath}.${mimeTypes.extension(result.mimeType || 'txt')}`,
hashInfo, compiler);
}
}
compileAllSync(rootDirectory, shouldCompile=null) {
let should = shouldCompile || function() {return true;};
forAllFilesSync(rootDirectory, (f) => {
if (!should(f)) return;
return this.compileSync(f, this.compilersByMimeType);
});
}
/*
* Other stuff
*/
/**
* Returns the passthrough compiler
*
* @private
*/
getPassthroughCompiler() {
return this.compilersByMimeType['text/plain'];
}
/**
* Determines whether we should even try to compile the content. Note that in
* some cases, content will still be in cache even if this returns true, and
* in other cases (isInNodeModules), we'll know explicitly to not even bother
* looking in the cache.
*
* @private
*/
static shouldPassthrough(hashInfo) {
return hashInfo.isMinified || hashInfo.isInNodeModules || hashInfo.hasSourceMap || hashInfo.isFileBinary;
}
}