-
Notifications
You must be signed in to change notification settings - Fork 50
/
Copy pathindex.js
292 lines (249 loc) · 10.9 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
/**
* Copyright 2018 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
import os from 'os';
import jsdom from 'jsdom';
import loaderUtils from 'loader-utils';
import LibraryTemplatePlugin from 'webpack/lib/LibraryTemplatePlugin';
import NodeTemplatePlugin from 'webpack/lib/node/NodeTemplatePlugin';
import NodeTargetPlugin from 'webpack/lib/node/NodeTargetPlugin';
import { DefinePlugin } from 'webpack';
import MemoryFs from 'memory-fs';
import { runChildCompiler, getRootCompiler, getBestModuleExport, stringToModule, convertPathToRelative } from './util';
import { applyEntry } from './webpack-util';
// Used to annotate this plugin's hooks in Tappable invocations
const PLUGIN_NAME = 'prerender-loader';
// Internal name used for the output bundle (never written to disk)
const FILENAME = 'ssr-bundle.js';
// Searches for fields of the form {{prerender}} or {{prerender:./some/module}}
const PRERENDER_REG = /\{\{prerender(?::\s*([^}]+?)\s*)?\}\}/;
/**
* prerender-loader can be applied to any HTML or JS file with the given options.
* @public
* @param {Options} options Options to control how Critters inlines CSS.
*
* @example
* // webpack.config.js
* module.exports = {
* plugins: [
* new HtmlWebpackPlugin({
* // `!!` tells webpack to skip any configured loaders for .html files
* // `?string` tells prerender-loader output a JS module exporting the HTML string
* template: '!!prerender-loader?string!index.html'
* })
* ]
* }
*
* @example
* // inline demo: assumes you have html-loader set up:
* import prerenderedHtml from '!prerender-loader!./file.html';
*/
export default function PrerenderLoader (content) {
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;
if (!options.entry) {
options.entry = matches[1];
}
}
options.templateContent = content;
}
const callback = this.async();
prerender(this._compilation, this.request, options, inject, this)
.then(output => {
callback(null, outputFilter(output));
})
.catch(err => {
// console.error(err);
callback(err);
});
}
async function prerender (parentCompilation, request, options, inject, loader) {
const parentCompiler = getRootCompiler(parentCompilation.compiler);
const context = parentCompiler.options.context || process.cwd();
const customEntry = options.entry && ([].concat(options.entry).pop() || '').trim();
const entry = customEntry ? ('./' + customEntry) : convertPathToRelative(context, parentCompiler.options.entry, './');
const outputOptions = {
// fix: some plugins ignore/bypass outputfilesystem, so use a temp directory and ignore any writes.
path: os.tmpdir(),
filename: FILENAME
};
// Only copy over allowed plugins (excluding them breaks extraction entirely).
const allowedPlugins = /(MiniCssExtractPlugin|ExtractTextPlugin)/i;
const plugins = (parentCompiler.options.plugins || []).filter(c => allowedPlugins.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.context = parentCompiler.context;
compiler.outputFileSystem = new MemoryFs();
// Define PRERENDER to be true within the SSR bundle
new DefinePlugin({
PRERENDER: 'true'
}).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}}`)
applyEntry(context, entry, 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(PLUGIN_NAME, addChildCache);
} else {
compiler.plugin('compilation', addChildCache);
}
const compilation = await runChildCompiler(compiler);
let result;
let dom, window, injectParent, injectNextSibling;
// A promise-like that never resolves and does not retain references to callbacks.
function BrokenPromise () {}
BrokenPromise.prototype.then = BrokenPromise.prototype.catch = BrokenPromise.prototype.finally = () => new BrokenPromise();
if (compilation.assets[compilation.options.output.filename]) {
// Get the compiled main bundle
const output = compilation.assets[compilation.options.output.filename].source();
// @TODO: provide a non-DOM option to allow turning off JSDOM entirely.
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>'), {
// suppress console-proxied eval() errors, but keep console proxying
virtualConsole: new jsdom.VirtualConsole({ omitJSDOMErrors: false }).sendTo(console),
// `url` sets the value returned by `window.location`, `document.URL`...
// Useful for routers that depend on the current URL (such as react-router or reach-router)
url: options.documentUrl || 'http://localhost',
// don't track source locations for performance reasons
includeNodeLocations: false,
// don't allow inline event handlers & script tag exec
runScripts: 'outside-only'
});
window = dom.window;
// Find the placeholder node for injection & remove it
const injectPlaceholder = window.document.getElementById('PRERENDER_INJECT');
if (injectPlaceholder) {
injectParent = injectPlaceholder.parentNode;
injectNextSibling = injectPlaceholder.nextSibling;
injectPlaceholder.remove();
}
// These are missing from JSDOM
let counter = 0;
window.requestAnimationFrame = () => ++counter;
window.cancelAnimationFrame = () => { };
// Never prerender Custom Elements: by skipping registration, we get only the Light DOM which is desirable.
window.customElements = {
define () {},
get () {},
upgrade () {},
whenDefined: () => new BrokenPromise()
};
// Fake MessagePort
window.MessagePort = function () {
(this.port1 = new window.EventTarget()).postMessage = () => {};
(this.port2 = new window.EventTarget()).postMessage = () => {};
};
// Never matches
window.matchMedia = () => ({ addListener () {} });
// Never register ServiceWorkers
if (!window.navigator) window.navigator = {};
window.navigator.serviceWorker = {
register: () => new BrokenPromise()
};
// When DefinePlugin isn't sufficient
window.PRERENDER = true;
// Inject a require shim
window.require = moduleId => {
const asset = compilation.assets[moduleId.replace(/^\.?\//g, '')];
if (!asset) {
try {
return require(moduleId);
} catch (e) {
throw Error(`Error: Module not found. attempted require("${moduleId}")`);
}
}
const mod = { exports: {} };
window.eval(`(function(exports, module, require){\n${asset.source()}\n})`)(mod.exports, mod, window.require);
return mod.exports;
};
// Invoke the SSR bundle within the JSDOM document and grab the exported/returned result
result = window.eval(output + '\nPRERENDER_RESULT');
}
// Deal with ES Module exports (just use the best guess):
if (result && typeof result === 'object') {
result = getBestModuleExport(result);
}
if (typeof result === 'function') {
result = result(options.params || null);
}
// 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 !== undefined && options.templateContent) {
const template = window.document.createElement('template');
template.innerHTML = result || '';
const content = template.content || template;
const parent = injectParent || window.document.body;
let child;
while ((child = content.firstChild)) {
parent.insertBefore(child, injectNextSibling || null);
}
} else if (inject) {
// Otherwise inject the prerendered HTML into the template
return options.templateContent.replace(PRERENDER_REG, result || '');
}
// dom.serialize() doesn't properly serialize HTML appended to document.body.
// return `<!DOCTYPE ${window.document.doctype.name}>${window.document.documentElement.outerHTML}`;
let serialized = dom.serialize();
if (!/^<!DOCTYPE /mi.test(serialized)) {
serialized = `<!DOCTYPE html>${serialized}`;
}
return serialized;
// // 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 worthwhile/necessary for the string return case
// if (injectParent || options.templateContent) {
// console.log(injectParent.outerHTML);
// (injectParent || document.body).insertAdjacentHTML('beforeend', result || '');
// // result = dom.serialize();
// } else {
// // Otherwise inject the prerendered HTML into the template
// return options.templateContent.replace(PRERENDER_REG, result || '');
// }
// }
// return dom.serialize();
// return result;
}