UNPKG

@lynx-js/template-webpack-plugin

Version:

Simplifies creation of Lynx template files to serve your webpack bundles

498 lines 21.8 kB
// 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, enableICU: false, enableA11y: true, enableAccessibilityElement: false, enableCSSInheritance: false, enableCSSInvalidation: false, enableCSSSelector: true, enableNewGesture: false, enableParallelElement: true, defaultDisplayLinear: true, enableRemoveCSSScope: false, pipelineSchedulerConfig: 0x00010000, 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'); compiler.hooks.initialize.tap(this.name, () => { // 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; } const filenameTemplate = this.#getAsyncFilenameTemplate(filename); // Ignore the encoded templates if (encodedTemplate.has(filenameTemplate)) { return; } 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, enableICU, enableA11y, enableAccessibilityElement, enableCSSInheritance, enableCSSInvalidation, enableCSSSelector, enableNewGesture, enableParallelElement, enableRemoveCSSScope, pipelineSchedulerConfig, removeDescendantSelectorScope, targetSdkVersion, defaultOverflowVisible, dsl, cssPlugins, } = this.#options; const isDev = process.env['NODE_ENV'] === 'development' || compiler.options.mode === 'development'; const css = 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, enableParallelElement, enableRemoveCSSScope, targetSdkVersion, defaultOverflowVisible, }, sourceContent: { dsl, appType: isAsync ? 'DynamicComponent' : 'card', config: { lepusStrict: true, useNewSwiper: true, enableICU, enableNewIntersectionObserver: true, enableNativeList: true, enableA11y, enableAccessibilityElement, customCSSInheritanceList, enableCSSInheritance, enableNewGesture, pipelineSchedulerConfig, removeDescendantSelectorScope, }, }, css: { ...css, chunks: assetsInfoByGroups.css, }, lepusCode: { // TODO: support multiple lepus chunks root: assetsInfoByGroups.mainThread[0], chunks: [], }, 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 } = 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()]; })), }, }; 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, }); 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