This commit is contained in:
2024-03-22 03:47:51 +05:30
parent 8bcf3d211e
commit 89819f6fe2
28440 changed files with 3211033 additions and 2 deletions

View File

@@ -0,0 +1,359 @@
/**
* react-loadable-ssr-addon
* @author Marcos Gonçalves <contact@themgoncalves.com>
* @version 1.0.1
*/
import fs from 'fs';
import path from 'path';
import url from 'url';
import { getFileExtension, computeIntegrity, hasEntry } from './utils';
// Webpack plugin name
const PLUGIN_NAME = 'ReactLoadableSSRAddon';
const WEBPACK_VERSION = require('webpack/package.json').version;
const WEBPACK_5 = WEBPACK_VERSION.startsWith('5.');
// Default plugin options
const defaultOptions = {
filename: 'assets-manifest.json',
integrity: false,
integrityAlgorithms: ['sha256', 'sha384', 'sha512'],
integrityPropertyName: 'integrity',
};
/**
* React Loadable SSR Add-on for Webpack
* @class ReactLoadableSSRAddon
* @desc Generate application assets manifest with its dependencies.
*/
class ReactLoadableSSRAddon {
/**
* @constructs ReactLoadableSSRAddon
* @param options
*/
constructor(options = defaultOptions) {
this.options = { ...defaultOptions, ...options };
this.compiler = null;
this.stats = null;
this.entrypoints = new Set();
this.assetsByName = new Map();
this.manifest = {};
}
/**
* Check if request is from Dev Server
* aka webpack-dev-server
* @method isRequestFromDevServer
* @returns {boolean} - True or False
*/
get isRequestFromDevServer() {
if (process.argv.some((arg) => arg.includes('webpack-dev-server'))) { return true; }
const { outputFileSystem, outputFileSystem: { constructor: { name } } } = this.compiler;
return outputFileSystem && name === 'MemoryFileSystem';
}
/**
* Get assets manifest output path
* @readonly
* @method manifestOutputPath
* @returns {string} - Output path containing path + filename.
*/
get manifestOutputPath() {
const { filename } = this.options;
if (path.isAbsolute(filename)) {
return filename;
}
const { outputPath, options: { devServer } } = this.compiler;
if (this.isRequestFromDevServer && devServer) {
let devOutputPath = (devServer.outputPath || outputPath || '/');
if (devOutputPath === '/') {
console.warn('Please use an absolute path in options.output when using webpack-dev-server.');
devOutputPath = this.compiler.context || process.cwd();
}
return path.resolve(devOutputPath, filename);
}
return path.resolve(outputPath, filename);
}
/**
* Get application assets chunks
* @method getAssets
* @param {array} assetsChunk - Webpack application chunks
* @returns {Map<string, object>}
*/
getAssets(assetsChunk) {
for (let i = 0; i < assetsChunk.length; i += 1) {
const chunk = assetsChunk[i];
const {
id, files, siblings = [], hash,
} = chunk;
const keys = this.getChunkOrigin(chunk);
for (let j = 0; j < keys.length; j += 1) {
this.assetsByName.set(keys[j], {
id, files, hash, siblings,
});
}
}
return this.assetsByName;
}
/**
* Get Application Entry points
* @method getEntrypoints
* @param {object} entrypoints - Webpack entry points
* @returns {Set<string>} - Application Entry points
*/
getEntrypoints(entrypoints) {
const entry = Object.keys(entrypoints);
for (let i = 0; i < entry.length; i += 1) {
this.entrypoints.add(entry[i]);
}
return this.entrypoints;
}
/**
* Get application chunk origin
* @method getChunkOrigin
* @param {object} id - Webpack application chunk id
* @param {object} names - Webpack application chunk names
* @param {object} modules - Webpack application chunk modules
* @returns {array} Chunk Keys
*/
/* eslint-disable class-methods-use-this */
getChunkOrigin({ id, names, modules }) {
const origins = new Set();
if (!WEBPACK_5) {
// webpack 5 doesn't have 'reasons' on chunks any more
// this is a dirty solution to make it work without throwing
// an error, but does need tweaking to make everything work properly.
for (let i = 0; i < modules.length; i += 1) {
const { reasons } = modules[i];
for (let j = 0; j < reasons.length; j += 1) {
const reason = reasons[j];
const type = reason.dependency ? reason.dependency.type : null;
const userRequest = reason.dependency
? reason.dependency.userRequest
: null;
if (type === 'import()') {
origins.add(userRequest);
}
}
}
}
if (origins.size === 0) { return [names[0] || id]; }
if (this.entrypoints.has(names[0])) {
origins.add(names[0]);
}
return Array.from(origins);
}
/* eslint-enabled */
/**
* Webpack apply method.
* @method apply
* @param {object} compiler - Webpack compiler object
* It represents the fully configured Webpack environment.
* @See {@link https://webpack.js.org/concepts/plugins/#anatomy}
*/
apply(compiler) {
this.compiler = compiler;
// @See {@Link https://webpack.js.org/api/compiler-hooks/}
compiler.hooks.emit.tapAsync(PLUGIN_NAME, this.handleEmit.bind(this));
}
/**
* Get Minimal Stats Chunks
* @description equivalent of getting stats.chunks but much less in size & memory usage
* It tries to mimic https://github.com/webpack/webpack/blob/webpack-4/lib/Stats.js#L632
* implementation without expensive operations
* @param {array} compilationChunks
* @param {array} chunkGraph
* @returns {array}
*/
getMinimalStatsChunks(compilationChunks, chunkGraph) {
const compareId = (a, b) => {
if (typeof a !== typeof b) {
return typeof a < typeof b ? -1 : 1;
}
if (a < b) return -1;
if (a > b) return 1;
return 0;
};
return this.ensureArray(compilationChunks).reduce((chunks, chunk) => {
const siblings = new Set();
if (chunk.groupsIterable) {
const chunkGroups = Array.from(chunk.groupsIterable);
for (let i = 0; i < chunkGroups.length; i += 1) {
const group = Array.from(chunkGroups[i].chunks);
for (let j = 0; j < group.length; j += 1) {
const sibling = group[j];
if (sibling !== chunk) siblings.add(sibling.id);
}
}
}
chunk.ids.forEach((id) => {
chunks.push({
id,
names: chunk.name ? [chunk.name] : [],
files: this.ensureArray(chunk.files).slice(),
hash: chunk.renderedHash,
siblings: Array.from(siblings).sort(compareId),
// Webpack5 emit deprecation warning for chunk.getModules()
// "DEP_WEBPACK_CHUNK_GET_MODULES"
modules: WEBPACK_5 ? chunkGraph.getChunkModules(chunk) : chunk.getModules(),
});
});
return chunks;
}, []);
}
/**
* Handles emit event from Webpack
* @desc The Webpack Compiler begins with emitting the generated assets.
* Here plugins have the last chance to add assets to the `c.assets` array.
* @See {@Link https://github.com/webpack/docs/wiki/plugins#emitc-compilation-async}
* @method handleEmit
* @param {object} compilation
* @param {function} callback
*/
handleEmit(compilation, callback) {
this.stats = compilation.getStats().toJson({
all: false,
entrypoints: true,
}, true);
this.options.publicPath = (compilation.outputOptions
? compilation.outputOptions.publicPath
: compilation.options.output.publicPath)
|| '';
this.getEntrypoints(this.stats.entrypoints);
this.getAssets(this.getMinimalStatsChunks(compilation.chunks, compilation.chunkGraph));
this.processAssets(compilation.assets);
this.writeAssetsFile();
callback();
}
/**
* Process Application Assets Manifest
* @method processAssets
* @param {object} originAssets - Webpack raw compilations assets
*/
/* eslint-disable object-curly-newline, no-restricted-syntax */
processAssets(originAssets) {
const assets = {};
const origins = {};
const { entrypoints } = this;
this.assetsByName.forEach((value, key) => {
const { files, id, siblings, hash } = value;
if (!origins[key]) { origins[key] = []; }
siblings.push(id);
for (let i = 0; i < siblings.length; i += 1) {
const sibling = siblings[i];
if (!origins[key].includes(sibling)) {
origins[key].push(sibling);
}
}
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
const currentAsset = originAssets[file] || {};
const ext = getFileExtension(file).replace(/^\.+/, '').toLowerCase();
if (!assets[id]) { assets[id] = {}; }
if (!assets[id][ext]) { assets[id][ext] = []; }
if (!hasEntry(assets[id][ext], 'file', file)) {
const shouldComputeIntegrity = Object.keys(currentAsset)
&& this.options.integrity
&& !currentAsset[this.options.integrityPropertyName];
if (shouldComputeIntegrity) {
currentAsset[this.options.integrityPropertyName] = computeIntegrity(
this.options.integrityAlgorithms,
currentAsset.source(),
);
}
assets[id][ext].push({
file,
hash,
publicPath: url.resolve(this.options.publicPath || '', file),
integrity: currentAsset[this.options.integrityPropertyName],
});
}
}
});
// create assets manifest object
this.manifest = {
entrypoints: Array.from(entrypoints),
origins,
assets,
};
}
/**
* Write Assets Manifest file
* @method writeAssetsFile
*/
writeAssetsFile() {
const filePath = this.manifestOutputPath;
const fileDir = path.dirname(filePath);
const json = JSON.stringify(this.manifest, null, 2);
try {
if (!fs.existsSync(fileDir)) {
fs.mkdirSync(fileDir);
}
} catch (err) {
if (err.code !== 'EEXIST') {
throw err;
}
}
fs.writeFileSync(filePath, json);
}
/**
* Ensure that given source is an array (webpack 5 switches a lot of Arrays to Sets)
* @method ensureArray
* @function
* @param {*[]|Set<any>} source
* @returns {*[]}
*/
ensureArray(source) {
if (WEBPACK_5) {
return Array.from(source);
}
return source;
}
}
export { defaultOptions };
export default ReactLoadableSSRAddon;

View File

@@ -0,0 +1,80 @@
import test from 'ava';
import path from 'path';
import fs from 'fs';
import waitForExpect from 'wait-for-expect';
import webpack from 'webpack';
import config from '../webpack.config';
import ReactLoadableSSRAddon, { defaultOptions } from './ReactLoadableSSRAddon';
/* eslint-disable consistent-return, import/no-dynamic-require, global-require */
let outputPath;
let manifestOutputPath;
const runWebpack = (configuration, end, callback) => {
webpack(configuration, (err, stats) => {
if (err) {
return end(err);
}
if (stats.hasErrors()) {
return end(stats.toString());
}
callback();
end();
});
};
test.beforeEach(() => {
const publicPathSanitized = config.output.publicPath.slice(1, -1);
outputPath = path.resolve('./example', publicPathSanitized);
manifestOutputPath = path.resolve(outputPath, defaultOptions.filename);
});
test.cb('outputs with default settings', (t) => {
config.plugins = [
new ReactLoadableSSRAddon(),
];
runWebpack(config, t.end, () => {
const feedback = fs.existsSync(manifestOutputPath) ? 'pass' : 'fail';
t[feedback]();
});
});
test.cb('outputs with custom filename', (t) => {
const filename = 'new-assets-manifest.json';
config.plugins = [
new ReactLoadableSSRAddon({
filename,
}),
];
runWebpack(config, t.end, () => {
const feedback = fs.existsSync(manifestOutputPath.replace(defaultOptions.filename, filename)) ? 'pass' : 'fail';
t[feedback]();
});
});
test.cb('outputs with integrity', (t) => {
config.plugins = [
new ReactLoadableSSRAddon({
integrity: true,
}),
];
runWebpack(config, t.end, async () => {
const manifest = require(`${manifestOutputPath}`);
await waitForExpect(() => {
Object.keys(manifest.assets).forEach((asset) => {
manifest.assets[asset].js.forEach(({ integrity }) => {
t.truthy(integrity);
});
});
});
});
});

View File

@@ -0,0 +1,37 @@
/**
* react-loadable-ssr-addon
* @author Marcos Gonçalves <contact@themgoncalves.com>
* @version 1.0.1
*/
import { unique } from './utils';
/**
* getBundles
* @param {object} manifest - The assets manifest content generate by ReactLoadableSSRAddon
* @param {array} chunks - Chunks list to be loaded
* @returns {array} - Assets list group by file type
*/
/* eslint-disable no-param-reassign */
function getBundles(manifest, chunks) {
if (!manifest || !chunks) { return {}; }
const assetsKey = chunks.reduce((key, chunk) => {
if (manifest.origins[chunk]) {
key = unique([...key, ...manifest.origins[chunk]]);
}
return key;
}, []);
return assetsKey.reduce((bundle, asset) => {
Object.keys(manifest.assets[asset] || {}).forEach((key) => {
const content = manifest.assets[asset][key];
if (!bundle[key]) { bundle[key] = []; }
bundle[key] = unique([...bundle[key], ...content]);
});
return bundle;
}, {});
}
/* eslint-enabled */
export default getBundles;

View File

@@ -0,0 +1,49 @@
import test from 'ava';
import path from 'path';
import getBundles from './getBundles';
import config from '../webpack.config';
import manifest from '../example/dist/react-loadable-ssr-addon'; // eslint-disable-line import/no-unresolved, import/extensions
const modules = ['./Header', './multilevel/Multilevel', './SharedMultilevel', '../../SharedMultilevel'];
const fileType = ['js'];
let bundles;
test.beforeEach(() => {
bundles = getBundles(manifest, [...manifest.entrypoints, ...modules]);
});
test('returns the correct bundle size and content', (t) => {
t.true(Object.keys(bundles).length === fileType.length);
fileType.forEach((type) => !!bundles[type]);
});
test('returns the correct bundle infos', (t) => {
fileType.forEach((type) => {
bundles[type].forEach((bundle) => {
const expectedPublichPath = path.resolve(config.output.publicPath, bundle.file);
t.true(bundle.file !== '');
t.true(bundle.hash !== '');
t.true(bundle.publicPath === expectedPublichPath);
});
});
});
test('returns nothing when there is no match', (t) => {
bundles = getBundles(manifest, ['foo-bar', 'foo', null, undefined]);
t.true(Object.keys(bundles).length === 0);
});
test('should work even with null/undefined manifest or modules', (t) => {
bundles = getBundles(manifest, null);
t.true(Object.keys(bundles).length === 0);
bundles = getBundles(null, []);
t.true(Object.keys(bundles).length === 0);
bundles = getBundles([], null);
t.true(Object.keys(bundles).length === 0);
});

View File

@@ -0,0 +1,11 @@
/**
* react-loadable-ssr-addon
* @author Marcos Gonçalves <contact@themgoncalves.com>
* @version 1.0.1
*/
import ReactLoadableSSRAddon from './ReactLoadableSSRAddon';
import getBundles from './getBundles';
module.exports = ReactLoadableSSRAddon;
module.exports.getBundles = getBundles;

View File

@@ -0,0 +1,29 @@
/**
* react-loadable-ssr-addon
* @author Marcos Gonçalves <contact@themgoncalves.com>
* @version 1.0.1
*/
import crypto from 'crypto';
/**
* Compute SRI Integrity
* @func computeIntegrity
* See {@link https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity Subresource Integrity} at MDN
* @param {array} algorithms - The algorithms you want to use when hashing `content`
* @param {string} source - File contents you want to hash
* @return {string} SRI hash
*/
function computeIntegrity(algorithms, source) {
return Array.isArray(algorithms)
? algorithms.map((algorithm) => {
const hash = crypto
.createHash(algorithm)
.update(source, 'utf8')
.digest('base64');
return `${algorithm}-${hash}`;
}).join(' ')
: '';
}
export default computeIntegrity;

View File

@@ -0,0 +1,25 @@
/**
* react-loadable-ssr-addon
* @author Marcos Gonçalves <contact@themgoncalves.com>
* @version 1.0.1
*/
/**
* Get file extension
* @method getFileExtension
* @static
* @param {string} filename - File name
* @returns {string} - File extension
*/
function getFileExtension(filename) {
if (!filename || typeof filename !== 'string') { return ''; }
const fileExtRegex = /\.\w{2,4}\.(?:map|gz)$|\.\w+$/i;
const name = filename.split(/[?#]/)[0]; // eslint-disable-line prefer-destructuring
const ext = name.match(fileExtRegex);
return ext && ext.length ? ext[0] : '';
}
export default getFileExtension;

View File

@@ -0,0 +1,36 @@
import test from 'ava';
import getFileExtension from './getFileExtension';
test('returns the correct file extension', (t) => {
const extensions = ['.jpeg', '.js', '.css', '.json', '.xml'];
const filePath = 'source/static/images/hello-world';
extensions.forEach((ext) => {
t.true(getFileExtension(`${filePath}${ext}`) === ext);
});
});
test('sanitize file hash', (t) => {
const hashes = ['?', '#'];
const filePath = 'source/static/images/hello-world.jpeg';
hashes.forEach((hash) => {
t.true(getFileExtension(`${filePath}${hash}d587bbd6e38337f5accd`) === '.jpeg');
});
});
test('returns empty string when there is no file extension', (t) => {
const filePath = 'source/static/resource';
t.true(getFileExtension(filePath) === '');
});
test('should work even with null/undefined arg', (t) => {
const filePaths = ['', null, undefined];
filePaths.forEach((path) => {
t.true(getFileExtension(path) === '');
});
});

View File

@@ -0,0 +1,25 @@
/**
* react-loadable-ssr-addon
* @author Marcos Gonçalves <contact@themgoncalves.com>
* @version 1.0.1
*/
/**
* Checks if object array already contains given value
* @method hasEntry
* @function
* @param {array} target - Object array to be inspected
* @param {string} targetKey - Object key to look for
* @param {string} searchFor - Value to search existence
* @returns {boolean}
*/
export default function hasEntry(target, targetKey, searchFor) {
if (!target) { return false; }
for (let i = 0; i < target.length; i += 1) {
const file = target[i][targetKey];
if (file === searchFor) { return true; }
}
return false;
}

View File

@@ -0,0 +1,46 @@
import test from 'ava';
import hasEntry from './hasEntry';
const assets = [
{
file: 'content.chunk.js',
hash: 'd41d8cd98f00b204e9800998ecf8427e',
publicPath: './',
integrity: null,
},
{
file: 'header.chunk.js',
hash: '699f4bd49870f2b90e1d1596d362efcb',
publicPath: './',
integrity: null,
},
{
file: 'shared-multilevel.chunk.js',
hash: 'ab7b8b1c1d5083c17a39ccd2962202e1',
publicPath: './',
integrity: null,
},
];
test('should flag as has entry', (t) => {
const fileName = 'header.chunk.js';
t.true(hasEntry(assets, 'file', fileName));
});
test('should flag as has no entry', (t) => {
const fileName = 'footer.chunk.js';
t.false(hasEntry(assets, 'file', fileName));
});
test('should work even with null/undefined target', (t) => {
const targets = [[], null, undefined];
targets.forEach((target) => {
t.false(hasEntry(target, 'file', 'foo.js'));
});
});

View File

@@ -0,0 +1,4 @@
export { default as computeIntegrity } from './computeIntegrity';
export { default as getFileExtension } from './getFileExtension';
export { default as unique } from './unique';
export { default as hasEntry } from './hasEntry';

View File

@@ -0,0 +1,16 @@
/**
* react-loadable-ssr-addon
* @author Marcos Gonçalves <contact@themgoncalves.com>
* @version 1.0.1
*/
/**
* Clean array to unique values
* @method unique
* @function
* @param {array} array - Array to be inspected
* @returns {array} - Array with unique values
*/
export default function unique(array) {
return array.filter((elem, pos, arr) => arr.indexOf(elem) === pos);
}

View File

@@ -0,0 +1,22 @@
import test from 'ava';
import unique from './unique';
test('it filters duplicated entries', (t) => {
const duplicated = ['two', 'four'];
const raw = ['one', 'two', 'three', 'four'];
const filtered = unique([...raw, ...duplicated]);
duplicated.forEach((dup) => {
t.true(filtered.filter((item) => item === dup).length === 1);
});
});
test('should work with null/undefined values', (t) => {
const falsy = [null, undefined];
const raw = ['one', 'two', 'three', 'four'];
const filtered = unique([...raw, ...falsy]);
falsy.forEach((value) => {
t.true(filtered.includes(value));
});
});