@lynx-js/template-webpack-plugin
Version:
Simplifies creation of Lynx template files to serve your webpack bundles
499 lines • 21.7 kB
JavaScript
// Copyright 2024 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
var _a;
import path from 'node:path';
import { AsyncSeriesBailHook, AsyncSeriesWaterfallHook, SyncWaterfallHook, } from '@rspack/lite-tapable';
import groupBy from 'object.groupby';
import { RuntimeGlobals } from '@lynx-js/webpack-runtime-globals';
import { cssChunksToMap } from './css/cssChunksToMap.js';
import { createLynxAsyncChunksRuntimeModule } from './LynxAsyncChunksRuntimeModule.js';
const LynxTemplatePluginHooksMap = new WeakMap();
/**
* Add hooks to the webpack compilation object to allow foreign plugins to
* extend the LynxTemplatePlugin
*/
function createLynxTemplatePluginHooks() {
return {
asyncChunkName: new SyncWaterfallHook(['pluginArgs']),
beforeEncode: new AsyncSeriesWaterfallHook(['pluginArgs']),
encode: new AsyncSeriesBailHook(['pluginArgs']),
beforeEmit: new AsyncSeriesWaterfallHook(['pluginArgs']),
afterEmit: new AsyncSeriesWaterfallHook(['pluginArgs']),
};
}
/**
* LynxTemplatePlugin
*
* @public
*/
export class LynxTemplatePlugin {
options;
constructor(options) {
this.options = options;
}
/**
* Returns all public hooks of the Lynx template webpack plugin for the given compilation
*/
static getLynxTemplatePluginHooks(compilation) {
let hooks = LynxTemplatePluginHooksMap.get(compilation);
// Setup the hooks only once
if (hooks === undefined) {
LynxTemplatePluginHooksMap.set(compilation, hooks = createLynxTemplatePluginHooks());
}
return hooks;
}
/**
* `defaultOptions` is the default options that the {@link LynxTemplatePlugin} uses.
*
* @example
* `defaultOptions` can be used to change part of the option and keep others as the default value.
*
* ```js
* // webpack.config.js
* import { LynxTemplatePlugin } from '@lynx-js/template-webpack-plugin'
* export default {
* plugins: [
* new LynxTemplatePlugin({
* ...LynxTemplatePlugin.defaultOptions,
* enableRemoveCSSScope: true,
* }),
* ],
* }
* ```
*
* @public
*/
static defaultOptions = Object
.freeze({
filename: '[name].bundle',
lazyBundleFilename: 'async/[name].[fullhash].bundle',
intermediate: '.rspeedy',
chunks: 'all',
excludeChunks: [],
// lynx-specific
customCSSInheritanceList: undefined,
debugInfoOutside: true,
enableA11y: true,
enableAccessibilityElement: false,
enableCSSInheritance: false,
enableCSSInvalidation: false,
enableCSSSelector: true,
enableNewGesture: false,
defaultDisplayLinear: true,
enableRemoveCSSScope: false,
targetSdkVersion: '3.2',
defaultOverflowVisible: true,
removeDescendantSelectorScope: false,
dsl: 'react_nodiff',
experimental_isLazyBundle: false,
cssPlugins: [],
});
/**
* Convert the css chunks to css map.
*
* @param cssChunks - The CSS chunks content.
* @param options - The encode options.
* @returns The CSS map and css source.
*
* @example
* ```
* (console.log(await convertCSSChunksToMap(
* '.red { color: red; }',
* {
* targetSdkVersion: '3.2',
* enableCSSSelector: true,
* },
* )));
* ```
*/
static convertCSSChunksToMap(cssChunks, plugins, enableCSSSelector) {
return cssChunksToMap(cssChunks, plugins, enableCSSSelector);
}
/**
* The entry point of a webpack plugin.
* @param compiler - the webpack compiler
*/
apply(compiler) {
new LynxTemplatePluginImpl(compiler, Object.assign({}, LynxTemplatePlugin.defaultOptions, this.options));
}
}
class LynxTemplatePluginImpl {
name = 'LynxTemplatePlugin';
static #taskQueue = null;
hash;
constructor(compiler, options) {
this.#options = options;
const { createHash } = compiler.webpack.util;
this.hash = createHash(compiler.options.output.hashFunction ?? 'xxhash64');
// entryName to fileName conversion function
const userOptionFilename = this.#options.filename;
const filenameFunction = typeof userOptionFilename === 'function'
? userOptionFilename
// Replace '[name]' with entry name
: (entryName) => userOptionFilename.replace(/\[name\]/g, entryName);
/** output filenames for the given entry names */
const entryNames = Object.keys(compiler.options.entry);
const outputFileNames = new Set((entryNames.length > 0 ? entryNames : ['main']).map((name) => filenameFunction(name)));
outputFileNames.forEach((outputFileName) => {
// convert absolute filename into relative so that webpack can
// generate it at correct location
let filename = outputFileName;
if (path.resolve(filename) === path.normalize(filename)) {
filename = path.relative(
/** Once initialized the path is always a string */
compiler.options.output.path, filename);
}
compiler.hooks.thisCompilation.tap(this.name, (compilation) => {
compilation.hooks.processAssets.tapPromise({
name: this.name,
stage:
/**
* Generate the html after minification and dev tooling is done
* and source-map is generated
*/
compiler.webpack.Compilation
.PROCESS_ASSETS_STAGE_OPTIMIZE_HASH,
}, () => {
return this.#generateTemplate(compiler, compilation, filename);
});
});
});
compiler.hooks.thisCompilation.tap(this.name, compilation => {
const onceForChunkSet = new WeakSet();
const LynxAsyncChunksRuntimeModule = createLynxAsyncChunksRuntimeModule(compiler.webpack);
const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation);
compilation.hooks.runtimeRequirementInTree.for(RuntimeGlobals.lynxAsyncChunkIds).tap(this.name, (chunk, set) => {
if (onceForChunkSet.has(chunk)) {
return;
}
onceForChunkSet.add(chunk);
// TODO: only add `getFullHash` when using fullhash
set.add(compiler.webpack.RuntimeGlobals.getFullHash);
compilation.addRuntimeModule(chunk, new LynxAsyncChunksRuntimeModule((chunkName) => {
const filename = hooks.asyncChunkName.call(chunkName);
return this.#getAsyncFilenameTemplate(filename);
}));
});
compilation.hooks.processAssets.tapPromise({
name: this.name,
stage:
/**
* Generate the html after minification and dev tooling is done
* and source-map is generated
* and real content hash is generated
*/
compiler.webpack.Compilation
.PROCESS_ASSETS_STAGE_OPTIMIZE_HASH,
}, async () => {
await this.#generateAsyncTemplate(compilation);
});
});
// There are multiple `LynxTemplatePlugin`s registered in one compiler.
// We only want to tap `hooks.done` on one of them.
if (_a.#taskQueue === null) {
_a.#taskQueue = [];
compiler.hooks.done.tap(this.name, () => {
const queue = _a.#taskQueue;
_a.#taskQueue = [];
if (queue) {
Promise.all(queue.map(fn => fn()))
.catch((error) => {
compiler.getInfrastructureLogger('LynxTemplatePlugin').error(error);
});
}
});
}
}
async #generateTemplate(_compiler, compilation, filenameTemplate) {
// Get all entry point names for this template file
const entryNames = Array.from(compilation.entrypoints.keys());
const filteredEntryNames = this.#filterEntryChunks(entryNames, this.#options.chunks, this.#options.excludeChunks);
const assetsInfoByGroups = this.#getAssetsInformationByGroups(compilation, filteredEntryNames);
await this.#encodeByAssetsInformation(compilation, assetsInfoByGroups, filteredEntryNames, filenameTemplate, this.#options.intermediate,
/** isAsync */ this.#options.experimental_isLazyBundle);
}
static #asyncChunkGroups = new WeakMap();
#getAsyncChunkGroups(compilation) {
let asyncChunkGroups = _a.#asyncChunkGroups.get(compilation);
if (asyncChunkGroups) {
return asyncChunkGroups;
}
const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation);
asyncChunkGroups = groupBy(compilation.chunkGroups
.filter(cg => !cg.isInitial())
.filter(cg => cg.name !== null && cg.name !== undefined), cg => hooks.asyncChunkName.call(cg.name));
_a.#asyncChunkGroups.set(compilation, asyncChunkGroups);
return asyncChunkGroups;
}
#getAsyncFilenameTemplate(filename) {
return this.#options.lazyBundleFilename.replace(/\[name\]/, filename);
}
static #encodedTemplate = new WeakMap();
async #generateAsyncTemplate(compilation) {
const asyncChunkGroups = this.#getAsyncChunkGroups(compilation);
const intermediateRoot = path.dirname(this.#options.intermediate);
const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation);
// We cache the encoded template so that it will not be encoded twice
if (!_a.#encodedTemplate.has(compilation)) {
_a.#encodedTemplate.set(compilation, new Set());
}
const encodedTemplate = _a.#encodedTemplate.get(compilation);
await Promise.all(Object.entries(asyncChunkGroups).map(([entryName, chunkGroups]) => {
const chunkNames =
// We use the chunk name(provided by `webpackChunkName`) as filename
chunkGroups
.filter(cg => cg.name !== null && cg.name !== undefined)
.map(cg => hooks.asyncChunkName.call(cg.name));
const filename = Array.from(new Set(chunkNames)).join('_');
// If no filename is found, avoid generating async template
if (!filename) {
return Promise.resolve();
}
const filenameTemplate = this.#getAsyncFilenameTemplate(filename);
// Ignore the encoded templates
if (encodedTemplate.has(filenameTemplate)) {
return Promise.resolve();
}
encodedTemplate.add(filenameTemplate);
const asyncAssetsInfoByGroups = this.#getAssetsInformationByFilenames(compilation, chunkGroups.flatMap(cg => cg.getFiles()));
return this.#encodeByAssetsInformation(compilation, asyncAssetsInfoByGroups, [entryName], filenameTemplate, path.join(intermediateRoot, 'async', filename),
/** isAsync */ true);
}));
}
async #encodeByAssetsInformation(compilation, assetsInfoByGroups, entryNames, filenameTemplate, intermediate, isAsync) {
const compiler = compilation.compiler;
const { customCSSInheritanceList, debugInfoOutside, defaultDisplayLinear, enableA11y, enableAccessibilityElement, enableCSSInheritance, enableCSSInvalidation, enableCSSSelector, enableNewGesture, enableRemoveCSSScope, removeDescendantSelectorScope, targetSdkVersion, defaultOverflowVisible, dsl, cssPlugins, } = this.#options;
const isDev = process.env['NODE_ENV'] === 'development'
|| compiler.options.mode === 'development';
const initialCSS = cssChunksToMap(assetsInfoByGroups.css
.map(chunk => compilation.getAsset(chunk.name))
.filter((v) => !!v)
.map(asset => asset.source.source().toString()), cssPlugins, enableCSSSelector);
const encodeRawData = {
compilerOptions: {
enableFiberArch: true,
useLepusNG: true,
enableReuseContext: true,
bundleModuleMode: 'ReturnByFunction',
templateDebugUrl: '',
debugInfoOutside,
defaultDisplayLinear,
enableCSSInvalidation,
enableCSSSelector,
enableLepusDebug: isDev,
enableRemoveCSSScope,
targetSdkVersion,
defaultOverflowVisible,
},
sourceContent: {
dsl,
appType: isAsync ? 'DynamicComponent' : 'card',
config: {
lepusStrict: true,
useNewSwiper: true,
enableNewIntersectionObserver: true,
enableNativeList: true,
enableA11y,
enableAccessibilityElement,
customCSSInheritanceList,
enableCSSInheritance,
enableNewGesture,
removeDescendantSelectorScope,
},
},
css: {
...initialCSS,
chunks: assetsInfoByGroups.css,
},
lepusCode: {
// TODO: support multiple lepus chunks
root: assetsInfoByGroups.mainThread[0],
chunks: [],
filename: (() => {
const name = assetsInfoByGroups.mainThread[0]?.name;
if (name) {
return path.basename(name);
}
return undefined;
})(),
},
manifest: Object.fromEntries(assetsInfoByGroups.backgroundThread.map(asset => {
return [asset.name, asset.source.source().toString()];
})),
customSections: {},
};
const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation);
const { encodeData } = await hooks.beforeEncode.promise({
encodeData: encodeRawData,
filenameTemplate,
entryNames,
});
const { lepusCode, css } = encodeData;
const resolvedEncodeOptions = {
...encodeData,
css: {
...css,
chunks: undefined,
contentMap: undefined,
},
lepusCode: {
// TODO: support multiple lepus chunks
root: lepusCode.root?.source.source().toString(),
lepusChunk: Object.fromEntries(lepusCode.chunks.map(asset => {
return [asset.name, asset.source.source().toString()];
})),
filename: lepusCode.filename,
},
};
const debugInfoPath = path.posix.format({
dir: intermediate,
base: 'debug-info.json',
});
// TODO: Support publicPath function
if (typeof compiler.options.output.publicPath === 'string'
&& compiler.options.output.publicPath !== 'auto'
&& compiler.options.output.publicPath !== '/') {
resolvedEncodeOptions.compilerOptions['templateDebugUrl'] = new URL(debugInfoPath, compiler.options.output.publicPath).toString();
}
const { RawSource } = compiler.webpack.sources;
if (isDebug() || isDev) {
compilation.emitAsset(path.format({
dir: intermediate,
base: 'tasm.json',
}), new RawSource(JSON.stringify(resolvedEncodeOptions, null, 2)));
Object.entries(resolvedEncodeOptions.lepusCode.lepusChunk).forEach(([name, content]) => {
compilation.emitAsset(path.format({
dir: intermediate,
name,
ext: '.js',
}), new RawSource(content));
});
}
try {
const { buffer, debugInfo } = await hooks.encode.promise({
encodeOptions: resolvedEncodeOptions,
intermediate,
});
const filename = compilation.getPath(filenameTemplate, {
// @ts-expect-error we have a mock chunk here to make contenthash works in webpack
chunk: {},
contentHash: this.hash.update(buffer).digest('hex').toString(),
});
const { template } = await hooks.beforeEmit.promise({
finalEncodeOptions: resolvedEncodeOptions,
debugInfo,
template: buffer,
outputName: filename,
mainThreadAssets: [lepusCode.root, ...encodeData.lepusCode.chunks]
.filter(i => i !== undefined),
cssChunks: assetsInfoByGroups.css,
entryNames,
});
compilation.emitAsset(filename, new RawSource(template, false));
if (isDebug() || isDev) {
compilation.emitAsset(debugInfoPath, new RawSource(debugInfo));
}
await hooks.afterEmit.promise({ outputName: filename });
}
catch (error) {
if (error && typeof error === 'object' && 'error_msg' in error) {
compilation.errors.push(
// TODO: use more human-readable error message(i.e.: using sourcemap to get source code)
// or give webpack/rspack with location of bundle
new compiler.webpack.WebpackError(error.error_msg));
}
else {
compilation.errors.push(error);
}
}
}
/**
* Return all chunks from the compilation result which match the exclude and include filters
*/
#filterEntryChunks(chunks, includedChunks, excludedChunks) {
return chunks.filter((chunkName) => {
// Skip if the chunks should be filtered and the given chunk was not added explicitly
if (Array.isArray(includedChunks) && !includedChunks.includes(chunkName)) {
return false;
}
// Skip if the chunks should be filtered and the given chunk was excluded explicitly
if (excludedChunks.includes(chunkName)) {
return false;
}
// Add otherwise
return true;
});
}
/**
* The getAssetsInformationByGroups extracts the asset information of a webpack compilation for all given entry names.
*/
#getAssetsInformationByGroups(compilation, entryNames) {
const filenames = entryNames.flatMap(entryName => {
/** entryPointUnfilteredFiles - also includes hot module update files */
const entryPointUnfilteredFiles = compilation.entrypoints.get(entryName)
.getFiles();
return entryPointUnfilteredFiles.filter((chunkFile) => {
const asset = compilation.getAsset(chunkFile);
// Prevent hot-module files from being included:
const assetMetaInformation = asset?.info ?? {};
return !(assetMetaInformation.hotModuleReplacement
?? assetMetaInformation.development);
});
});
return this.#getAssetsInformationByFilenames(compilation, filenames);
}
#getAssetsInformationByFilenames(compilation, filenames) {
const assets = {
// Will contain all js and mjs files
backgroundThread: [],
// Will contain all css files
css: [],
// Will contain all lepus files
mainThread: [],
};
// Extract paths to .js, .lepus and .css files from the current compilation
const entryPointPublicPathMap = {};
const extensionRegexp = /\.(css|js)(?:\?|$)/;
filenames.forEach((filename) => {
const extMatch = extensionRegexp.exec(filename);
// Skip if the public path is not a .css, .mjs or .js file
if (!extMatch) {
return;
}
// Skip if this file is already known
// (e.g. because of common chunk optimizations)
if (entryPointPublicPathMap[filename]) {
return;
}
const asset = compilation.getAsset(filename);
if (asset.info['lynx:main-thread']) {
assets.mainThread.push(asset);
return;
}
entryPointPublicPathMap[filename] = true;
// ext will contain .js or .css, because .mjs recognizes as .js
const ext = (extMatch[1] === 'mjs' ? 'js' : extMatch[1]);
assets[ext === 'js' ? 'backgroundThread' : 'css'].push(asset);
});
return assets;
}
#options;
}
_a = LynxTemplatePluginImpl;
export function isDebug() {
if (!process.env['DEBUG']) {
return false;
}
const values = process.env['DEBUG'].toLocaleLowerCase().split(',');
return [
'rspeedy',
'*',
'rspeedy:*',
'rspeedy:template',
].some((key) => values.includes(key));
}
export function isRsdoctor() {
return process.env['RSDOCTOR'] === 'true';
}
//# sourceMappingURL=LynxTemplatePlugin.js.map