mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-13 01:07:18 +00:00
223 lines
7.9 KiB
JavaScript
223 lines
7.9 KiB
JavaScript
const jsdom = require('jsdom');
|
|
const os = require('os');
|
|
// const util = require('util');
|
|
const path = require('path');
|
|
const loaderUtils = require('loader-utils');
|
|
const LibraryTemplatePlugin = require('webpack/lib/LibraryTemplatePlugin');
|
|
const NodeTemplatePlugin = require('webpack/lib/node/NodeTemplatePlugin');
|
|
const NodeTargetPlugin = require('webpack/lib/node/NodeTargetPlugin');
|
|
const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin');
|
|
const DefinePlugin = require('webpack').DefinePlugin;
|
|
const MemoryFs = require('memory-fs');
|
|
|
|
const FILENAME = 'ssr-bundle.js';
|
|
|
|
const PRERENDER_REG = /\{\{prerender(?::\s*([^}]+)\s*)?\}\}/;
|
|
|
|
module.exports = function PrerenderLoader (content) {
|
|
// const { string, disabled, ...options } = getOptions()
|
|
const options = loaderUtils.getOptions(this) || {};
|
|
const outputFilter = options.as === 'string' || options.string ? stringToModule : String;
|
|
|
|
if (options.disabled === true) {
|
|
return outputFilter(content);
|
|
}
|
|
|
|
// When applied to HTML, attempts to inject into a specified {{prerender}} field.
|
|
// @note: this is only used when the entry module exports a String or function
|
|
// that resolves to a String, otherwise the whole document is serialized.
|
|
let inject = false;
|
|
if (!this.request.match(/.(js|ts)x?$/i)) {
|
|
const matches = content.match(PRERENDER_REG);
|
|
if (matches) {
|
|
inject = true;
|
|
options.entry = matches[1];
|
|
}
|
|
options.templateContent = content;
|
|
}
|
|
|
|
const callback = this.async();
|
|
|
|
prerender(this, options, inject)
|
|
.then(output => {
|
|
callback(null, outputFilter(output));
|
|
})
|
|
.catch(err => {
|
|
console.error(err);
|
|
callback(err);
|
|
});
|
|
};
|
|
|
|
async function prerender (loaderContext, options, inject) {
|
|
const parentCompilation = loaderContext._compilation;
|
|
const parentCompiler = rootCompiler(parentCompilation.compiler);
|
|
const request = loaderContext.request;
|
|
const context = parentCompiler.options.context || process.cwd();
|
|
const entry = './' + ((options.entry && [].concat(options.entry).pop().trim()) || path.relative(context, parentCompiler.options.entry));
|
|
|
|
// undocumented option (to remove):
|
|
// !!prerender-loader?template=src/index.html!src/index.js
|
|
// if (!inject && options.template) {
|
|
// const loadModule = util.promisify(loaderContext.loadModule);
|
|
// const source = await loadModule('!!raw-loader!' + path.resolve(context, options.template));
|
|
// options.templateContent = source;
|
|
// }
|
|
|
|
const outputOptions = {
|
|
// fix for plugins not using outputfilesystem
|
|
path: os.tmpdir(),
|
|
filename: FILENAME
|
|
};
|
|
|
|
// Only copy over mini-extract-text-plugin (excluding it breaks extraction entirely)
|
|
const plugins = (parentCompiler.options.plugins || []).filter(c => /MiniCssExtractPlugin/i.test(c.constructor.name));
|
|
|
|
// Compile to an in-memory filesystem since we just want the resulting bundled code as a string
|
|
const compiler = parentCompilation.createChildCompiler('prerender', outputOptions, plugins);
|
|
compiler.outputFileSystem = new MemoryFs();
|
|
|
|
// Define PRERENDER to be true within the SSR bundle
|
|
new DefinePlugin({
|
|
PRERENDER: 'true',
|
|
document: 'undefined' // if (typeof document==='undefined') {}
|
|
}).apply(compiler);
|
|
|
|
// ... then define PRERENDER to be false within the client bundle
|
|
new DefinePlugin({
|
|
PRERENDER: 'false'
|
|
}).apply(parentCompiler);
|
|
|
|
// Compile to CommonJS to be executed by Node
|
|
new NodeTemplatePlugin(outputOptions).apply(compiler);
|
|
new NodeTargetPlugin().apply(compiler);
|
|
|
|
new LibraryTemplatePlugin('PRERENDER_RESULT', 'var').apply(compiler);
|
|
|
|
// Kick off compilation at our entry module (either the parent compiler's entry or a custom one defined via `{{prerender:entry.js}}`)
|
|
new SingleEntryPlugin(context, entry, undefined).apply(compiler);
|
|
|
|
// Set up cache inheritance for the child compiler
|
|
const subCache = 'subcache ' + request;
|
|
function addChildCache (compilation, data) {
|
|
if (compilation.cache) {
|
|
if (!compilation.cache[subCache]) compilation.cache[subCache] = {};
|
|
compilation.cache = compilation.cache[subCache];
|
|
}
|
|
}
|
|
if (compiler.hooks) {
|
|
compiler.hooks.compilation.tap('prerender-loader', addChildCache);
|
|
} else {
|
|
compiler.plugin('compilation', addChildCache);
|
|
}
|
|
|
|
const compilation = await runChildCompiler(compiler);
|
|
let result = '';
|
|
let dom, injectParent;
|
|
|
|
if (compilation.assets[compilation.options.output.filename]) {
|
|
// Get the compiled main bundle
|
|
const output = compilation.assets[compilation.options.output.filename].source();
|
|
|
|
const tpl = options.templateContent || '<!DOCTYPE html><html><head></head><body></body></html>';
|
|
dom = new jsdom.JSDOM(tpl.replace(PRERENDER_REG, '<div id="PRERENDER_INJECT"></div>'), {
|
|
// don't track source locations for performance reasons
|
|
includeNodeLocations: false,
|
|
// don't allow inline event handlers & script tag exec
|
|
runScripts: 'outside-only'
|
|
});
|
|
const { window } = dom;
|
|
|
|
// Find the placeholder node for injection & remove it
|
|
const injectPlaceholder = window.document.getElementById('PRERENDER_INJECT');
|
|
if (injectPlaceholder) {
|
|
injectParent = injectPlaceholder.parentNode;
|
|
injectPlaceholder.remove();
|
|
}
|
|
|
|
// These are missing from JSDOM
|
|
let counter = 0;
|
|
window.requestAnimationFrame = () => ++counter;
|
|
window.cancelAnimationFrame = () => {};
|
|
|
|
// Invoke the SSR bundle within the JSDOM document and grab the exported/returned result
|
|
result = window.eval(output + '\nPRERENDER_RESULT') || result;
|
|
|
|
// @todo this seems pointless
|
|
if (window.PRERENDER_RESULT != null) {
|
|
result = window.PRERENDER_RESULT;
|
|
}
|
|
}
|
|
|
|
// Deal with ES Module exports (just use the best guess):
|
|
if (result && result.__esModule === true) {
|
|
result = getBestModuleExport(result);
|
|
}
|
|
|
|
if (typeof result === 'function') {
|
|
// @todo any arguments worth passing here?
|
|
result = result();
|
|
}
|
|
|
|
// The entry can export or return a Promise in order to perform fully async prerendering:
|
|
if (result && result.then) {
|
|
result = await result;
|
|
}
|
|
|
|
// Returning or resolving to `null` / `undefined` defaults to serializing the whole document.
|
|
// Note: this pypasses `inject` because the document is already derived from the template.
|
|
if (result == null && dom) {
|
|
result = dom.serialize();
|
|
} else if (inject) {
|
|
// @todo determine if this is really necessary for the string return case
|
|
if (injectParent) {
|
|
injectParent.insertAdjacentHTML('beforeend', result || '');
|
|
} else {
|
|
// Otherwise inject the prerendered HTML into the template
|
|
result = options.templateContent.replace(PRERENDER_REG, result || '');
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Promisified version of compiler.runAsChild() with error hoisting and isolated output/assets
|
|
function runChildCompiler (compiler) {
|
|
return new Promise((resolve, reject) => {
|
|
// runAsChild() merges assets into the parent compilation, we don't want that.
|
|
compiler.compile((err, compilation) => {
|
|
compiler.parentCompilation.children.push(compilation);
|
|
if (err) return reject(err);
|
|
|
|
if (compilation.errors && compilation.errors.length) {
|
|
const errorDetails = compilation.errors.map(error => error.details).join('\n');
|
|
return reject(Error('Child compilation failed:\n' + errorDetails));
|
|
}
|
|
|
|
resolve(compilation);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Crawl up the compiler tree and return the outermost compiler instance
|
|
function rootCompiler (compiler) {
|
|
while (compiler.parentCompilation && compiler.parentCompilation.compiler) {
|
|
compiler = compiler.parentCompilation.compiler;
|
|
}
|
|
return compiler;
|
|
}
|
|
|
|
// Find the best possible export for an ES Module. Returns `undefined` for no exports.
|
|
function getBestModuleExport (exports) {
|
|
if (exports.default) {
|
|
return exports.default;
|
|
}
|
|
for (const prop in exports) {
|
|
if (prop !== '__esModule') {
|
|
return exports[prop];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wrap a String up into an ES Module that exports it
|
|
const stringToModule = str => 'export default ' + JSON.stringify(str);
|