diff --git a/packages/rspack/package.json b/packages/rspack/package.json index 2102939abb..0ba9f2c609 100644 --- a/packages/rspack/package.json +++ b/packages/rspack/package.json @@ -42,6 +42,7 @@ "less-loader": "11.1.0", "license-webpack-plugin": "^4.0.2", "loader-utils": "^2.0.3", + "parse5": "4.0.0", "sass": "^1.85.0", "sass-embedded": "^1.83.4", "sass-loader": "^16.0.4", diff --git a/packages/rspack/src/generators/convert-webpack/convert-webpack.spec.ts b/packages/rspack/src/generators/convert-webpack/convert-webpack.spec.ts index 04c2e15c0b..31cea20808 100644 --- a/packages/rspack/src/generators/convert-webpack/convert-webpack.spec.ts +++ b/packages/rspack/src/generators/convert-webpack/convert-webpack.spec.ts @@ -34,6 +34,7 @@ describe('Convert webpack', () => { module.exports = composePlugins( withNx(), withReact({ + useLegacyHtmlPlugin: true, // Uncomment this line if you don't want to use SVGR // See: https://react-svgr.com/ // svgr: false @@ -162,7 +163,7 @@ describe('Convert webpack', () => { */ export default composePlugins( withNx(), - withReact(), + withReact({ useLegacyHtmlPlugin: true }), withModuleFederation(config, { dts: false }) ); " @@ -262,7 +263,7 @@ describe('Convert webpack', () => { */ export default composePlugins( withNx(), - withReact(), + withReact({ useLegacyHtmlPlugin: true }), withModuleFederation(config, { dts: false }) ); " @@ -368,7 +369,7 @@ describe('Convert webpack', () => { */ export default composePlugins( withNx(), - withReact(), + withReact({ useLegacyHtmlPlugin: true }), withModuleFederation(config, { dts: false }) ); " diff --git a/packages/rspack/src/generators/convert-webpack/lib/transform-cjs.ts b/packages/rspack/src/generators/convert-webpack/lib/transform-cjs.ts index d4e8d2f841..6a6cb95a06 100644 --- a/packages/rspack/src/generators/convert-webpack/lib/transform-cjs.ts +++ b/packages/rspack/src/generators/convert-webpack/lib/transform-cjs.ts @@ -11,6 +11,111 @@ export function transformCjsConfigFile(tree: Tree, configPath: string) { transformWithModuleFederation(tree, configPath, scope); transformWithModuleFederationSSR(tree, configPath, scope); }); + + // Add useLegacyHtmlPlugin: true to withWeb() calls + transformWithWebCalls(tree, configPath); +} + +function transformWithWebCalls(tree: Tree, configPath: string) { + const configContents = tree.read(configPath, 'utf-8'); + const ast = tsquery.ast(configContents); + + // Find withWeb() calls + const withWebCallNodes = tsquery( + ast, + 'CallExpression > Identifier[name=withWeb]' + ); + + // If there are withWeb calls, update them + if (withWebCallNodes.length > 0) { + let newContents = configContents; + + for (const node of withWebCallNodes) { + const callExpr = node.parent; + if (!callExpr) continue; + + const startPos = callExpr.getStart(); + const endPos = callExpr.getEnd(); + const callText = configContents.substring(startPos, endPos); + + // Skip if useLegacyHtmlPlugin is already present + if (callText.includes('useLegacyHtmlPlugin')) { + continue; + } + + // If it's already withWeb({ ... }), add useLegacyHtmlPlugin: true to the options + if (callText.includes('{') && callText.includes('}')) { + const newCallText = callText.replace( + /\{\s*/, + '{ useLegacyHtmlPlugin: true,\n ' + ); + newContents = + newContents.substring(0, startPos) + + newCallText + + newContents.substring(endPos); + } else { + // If it's just withWeb(), replace with withWeb({ useLegacyHtmlPlugin: true }) + const newCallText = 'withWeb({ useLegacyHtmlPlugin: true })'; + newContents = + newContents.substring(0, startPos) + + newCallText + + newContents.substring(endPos); + } + } + + if (newContents !== configContents) { + tree.write(configPath, newContents); + } + return; + } + + // If no withWeb calls, check for withReact calls + const withReactCallNodes = tsquery( + ast, + 'CallExpression > Identifier[name=withReact]' + ); + if (withReactCallNodes.length === 0) { + return; + } + + let newContents = configContents; + + for (const node of withReactCallNodes) { + const callExpr = node.parent; + if (!callExpr) continue; + + const startPos = callExpr.getStart(); + const endPos = callExpr.getEnd(); + const callText = configContents.substring(startPos, endPos); + + // Skip if useLegacyHtmlPlugin is already present + if (callText.includes('useLegacyHtmlPlugin')) { + continue; + } + + // If it's already withReact({ ... }), add useLegacyHtmlPlugin: true to the options + if (callText.includes('{') && callText.includes('}')) { + const newCallText = callText.replace( + /\{\s*/, + '{ useLegacyHtmlPlugin: true,\n ' + ); + newContents = + newContents.substring(0, startPos) + + newCallText + + newContents.substring(endPos); + } else { + // If it's just withReact(), replace with withReact({ useLegacyHtmlPlugin: true }) + const newCallText = 'withReact({ useLegacyHtmlPlugin: true })'; + newContents = + newContents.substring(0, startPos) + + newCallText + + newContents.substring(endPos); + } + } + + if (newContents !== configContents) { + tree.write(configPath, newContents); + } } function transformComposePlugins( diff --git a/packages/rspack/src/generators/convert-webpack/lib/transform-esm.ts b/packages/rspack/src/generators/convert-webpack/lib/transform-esm.ts index a4630abfa2..69e4e32794 100644 --- a/packages/rspack/src/generators/convert-webpack/lib/transform-esm.ts +++ b/packages/rspack/src/generators/convert-webpack/lib/transform-esm.ts @@ -11,6 +11,111 @@ export function transformEsmConfigFile(tree: Tree, configPath: string) { transformWithModuleFederation(tree, configPath, scope); transformWithModuleFederationSSR(tree, configPath, scope); }); + + // Add useLegacyHtmlPlugin: true to withWeb() calls + transformWithWebCalls(tree, configPath); +} + +function transformWithWebCalls(tree: Tree, configPath: string) { + const configContents = tree.read(configPath, 'utf-8'); + const ast = tsquery.ast(configContents); + + // Find withWeb() calls + const withWebCallNodes = tsquery( + ast, + 'CallExpression > Identifier[name=withWeb]' + ); + + // If there are withWeb calls, update them + if (withWebCallNodes.length > 0) { + let newContents = configContents; + + for (const node of withWebCallNodes) { + const callExpr = node.parent; + if (!callExpr) continue; + + const startPos = callExpr.getStart(); + const endPos = callExpr.getEnd(); + const callText = configContents.substring(startPos, endPos); + + // Skip if useLegacyHtmlPlugin is already present + if (callText.includes('useLegacyHtmlPlugin')) { + continue; + } + + // If it's already withWeb({ ... }), add useLegacyHtmlPlugin: true to the options + if (callText.includes('{') && callText.includes('}')) { + const newCallText = callText.replace( + /\{\s*/, + '{ useLegacyHtmlPlugin: true,\n ' + ); + newContents = + newContents.substring(0, startPos) + + newCallText + + newContents.substring(endPos); + } else { + // If it's just withWeb(), replace with withWeb({ useLegacyHtmlPlugin: true }) + const newCallText = 'withWeb({ useLegacyHtmlPlugin: true })'; + newContents = + newContents.substring(0, startPos) + + newCallText + + newContents.substring(endPos); + } + } + + if (newContents !== configContents) { + tree.write(configPath, newContents); + } + return; + } + + // If no withWeb calls, check for withReact calls + const withReactCallNodes = tsquery( + ast, + 'CallExpression > Identifier[name=withReact]' + ); + if (withReactCallNodes.length === 0) { + return; + } + + let newContents = configContents; + + for (const node of withReactCallNodes) { + const callExpr = node.parent; + if (!callExpr) continue; + + const startPos = callExpr.getStart(); + const endPos = callExpr.getEnd(); + const callText = configContents.substring(startPos, endPos); + + // Skip if useLegacyHtmlPlugin is already present + if (callText.includes('useLegacyHtmlPlugin')) { + continue; + } + + // If it's already withReact({ ... }), add useLegacyHtmlPlugin: true to the options + if (callText.includes('{') && callText.includes('}')) { + const newCallText = callText.replace( + /\{\s*/, + '{ useLegacyHtmlPlugin: true,\n ' + ); + newContents = + newContents.substring(0, startPos) + + newCallText + + newContents.substring(endPos); + } else { + // If it's just withReact(), replace with withReact({ useLegacyHtmlPlugin: true }) + const newCallText = 'withReact({ useLegacyHtmlPlugin: true })'; + newContents = + newContents.substring(0, startPos) + + newCallText + + newContents.substring(endPos); + } + } + + if (newContents !== configContents) { + tree.write(configPath, newContents); + } } function transformComposePlugins( diff --git a/packages/rspack/src/plugins/utils/apply-web-config.ts b/packages/rspack/src/plugins/utils/apply-web-config.ts index 01c96bde15..d6baeb1584 100644 --- a/packages/rspack/src/plugins/utils/apply-web-config.ts +++ b/packages/rspack/src/plugins/utils/apply-web-config.ts @@ -4,11 +4,12 @@ import { type RuleSetRule, LightningCssMinimizerRspackPlugin, DefinePlugin, - HtmlRspackPlugin, CssExtractRspackPlugin, EnvironmentPlugin, RspackOptionsNormalized, + HtmlRspackPlugin, } from '@rspack/core'; +import { WriteIndexHtmlPlugin } from '../write-index-html-plugin'; import { instantiateScriptPlugins } from './instantiate-script-plugins'; import { join, resolve } from 'path'; import { getOutputHashFormat } from './hash-format'; @@ -66,16 +67,32 @@ export function applyWebConfig( plugins.push(...instantiateScriptPlugins(options)); } if (options.index && options.generateIndexHtml) { - plugins.push( - new HtmlRspackPlugin({ - template: options.index, - sri: options.subresourceIntegrity ? 'sha256' : undefined, - ...(options.baseHref ? { base: { href: options.baseHref } } : {}), - ...(config.output?.scriptType === 'module' - ? { scriptLoading: 'module' } - : {}), - }) - ); + if (options.useLegacyHtmlPlugin) { + plugins.push( + new WriteIndexHtmlPlugin({ + indexPath: options.index, + outputPath: 'index.html', + baseHref: + typeof options.baseHref === 'string' ? options.baseHref : undefined, + sri: options.subresourceIntegrity, + scripts: options.scripts, + styles: options.styles, + crossOrigin: + config.output?.scriptType === 'module' ? 'anonymous' : undefined, + }) + ); + } else { + plugins.push( + new HtmlRspackPlugin({ + template: options.index, + sri: options.subresourceIntegrity ? 'sha256' : undefined, + ...(options.baseHref ? { base: { href: options.baseHref } } : {}), + ...(config.output?.scriptType === 'module' + ? { scriptLoading: 'module' } + : {}), + }) + ); + } } const minimizer: RspackPluginInstance[] = []; diff --git a/packages/rspack/src/plugins/utils/models.ts b/packages/rspack/src/plugins/utils/models.ts index b7f9959360..fe193c734e 100644 --- a/packages/rspack/src/plugins/utils/models.ts +++ b/packages/rspack/src/plugins/utils/models.ts @@ -247,6 +247,10 @@ export interface NxAppRspackPluginOptions { * Whether to rebase absolute path for assets in postcss cli resources. */ rebaseRootRelative?: boolean; + /** + * Use the legacy WriteIndexHtmlPlugin instead of the built-in HtmlRspackPlugin. + */ + useLegacyHtmlPlugin?: boolean; } export interface NormalizedNxAppRspackPluginOptions @@ -260,4 +264,5 @@ export interface NormalizedNxAppRspackPluginOptions projectGraph: ProjectGraph; outputFileName: string; assets: AssetGlobPattern[]; + useLegacyHtmlPlugin: boolean; } diff --git a/packages/rspack/src/plugins/utils/plugins/normalize-options.ts b/packages/rspack/src/plugins/utils/plugins/normalize-options.ts index 94b5f43446..c1e9038eb6 100644 --- a/packages/rspack/src/plugins/utils/plugins/normalize-options.ts +++ b/packages/rspack/src/plugins/utils/plugins/normalize-options.ts @@ -106,6 +106,8 @@ export function normalizeOptions( ), generateIndexHtml: combinedPluginAndMaybeExecutorOptions.generateIndexHtml ?? true, + useLegacyHtmlPlugin: + combinedPluginAndMaybeExecutorOptions.useLegacyHtmlPlugin ?? false, main: combinedPluginAndMaybeExecutorOptions.main, namedChunks: combinedPluginAndMaybeExecutorOptions.namedChunks ?? !isProd, optimization: combinedPluginAndMaybeExecutorOptions.optimization ?? isProd, diff --git a/packages/rspack/src/plugins/write-index-html-plugin.ts b/packages/rspack/src/plugins/write-index-html-plugin.ts new file mode 100644 index 0000000000..59fd28660d --- /dev/null +++ b/packages/rspack/src/plugins/write-index-html-plugin.ts @@ -0,0 +1,378 @@ +import * as rspack from '@rspack/core'; +import { Compiler } from '@rspack/core'; +import { createHash } from 'crypto'; +import { readFileSync } from 'fs'; + +import { EmittedFile, ExtraEntryPoint } from '../utils/model'; +import { interpolateEnvironmentVariablesToIndex } from '../utils/webpack/interpolate-env-variables-to-index'; +import { generateEntryPoints } from '../utils/webpack/package-chunk-sort'; +import { extname } from 'path'; + +const parse5 = require('parse5'); + +export interface WriteIndexHtmlOptions { + indexPath: string; + outputPath: string; + baseHref?: string; + deployUrl?: string; + sri?: boolean; + scripts?: ExtraEntryPoint[]; + styles?: ExtraEntryPoint[]; + crossOrigin?: 'none' | 'anonymous' | 'use-credentials'; +} + +export class WriteIndexHtmlPlugin { + constructor(private readonly options: WriteIndexHtmlOptions) {} + + apply(compiler: Compiler) { + const { + outputPath, + indexPath, + baseHref, + deployUrl, + sri = false, + scripts = [], + styles = [], + crossOrigin, + } = this.options; + compiler.hooks.thisCompilation.tap( + 'WriteIndexHtmlPlugin', + (compilation) => { + compilation.hooks.processAssets.tap( + { + name: 'WriteIndexHtmlPlugin', + // After minification and sourcemaps are done + stage: rspack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE, + }, + () => { + const moduleFiles = this.getEmittedFiles(compilation); + const files = moduleFiles.filter((x) => x.extension === '.css'); + let content = readFileSync(indexPath).toString(); + content = this.stripBom(content); + compilation.assets[outputPath] = this.augmentIndexHtml({ + input: outputPath, + inputContent: interpolateEnvironmentVariablesToIndex( + content, + deployUrl + ), + baseHref, + deployUrl, + crossOrigin, + sri, + entrypoints: generateEntryPoints({ scripts, styles }), + files: this.filterAndMapBuildFiles(files, ['.js', '.css']), + moduleFiles: this.filterAndMapBuildFiles(moduleFiles, ['.js']), + loadOutputFile: (filePath) => + compilation.assets[filePath].source().toString(), + }); + } + ); + } + ); + } + + private getEmittedFiles(compilation: rspack.Compilation): EmittedFile[] { + const files: EmittedFile[] = []; + // adds all chunks to the list of emitted files such as lazy loaded modules + for (const chunk of compilation.chunks) { + for (const file of chunk.files) { + files.push({ + // The id is guaranteed to exist at this point in the compilation process + id: chunk.id.toString(), + name: chunk.name, + file, + extension: extname(file), + initial: chunk.isOnlyInitial(), + }); + } + } + // other all files + for (const file of Object.keys(compilation.assets)) { + files.push({ + file, + extension: extname(file), + initial: false, + asset: true, + }); + } + // dedupe + return files.filter( + ({ file, name }, index) => + files.findIndex( + (f) => f.file === file && (!name || name === f.name) + ) === index + ); + } + + private stripBom(data: string) { + return data.replace(/^\uFEFF/, ''); + } + + private augmentIndexHtml(params: { + /* Input file name (e. g. index.html) */ + input: string; + /* Input contents */ + inputContent: string; + baseHref?: string; + deployUrl?: string; + sri: boolean; + /** crossorigin attribute setting of elements that provide CORS support */ + crossOrigin?: 'none' | 'anonymous' | 'use-credentials'; + /* + * Files emitted by the build. + */ + files: { + file: string; + name: string; + extension: string; + }[]; + /** Files that should be added using 'module'. */ + moduleFiles?: { + file: string; + name: string; + extension: string; + }[]; + /* + * Function that loads a file used. + * This allows us to use different routines within the IndexHtmlWebpackPlugin and + * when used without this plugin. + */ + loadOutputFile: (file: string) => string; + /** Used to sort the inseration of files in the HTML file */ + entrypoints: string[]; + }): rspack.sources.Source { + const { loadOutputFile, files, moduleFiles = [], entrypoints } = params; + + let { crossOrigin = 'none' } = params; + if (params.sri && crossOrigin === 'none') { + crossOrigin = 'anonymous'; + } + + const stylesheets = new Set(); + const scripts = new Set(); + + // Sort files in the order we want to insert them by entrypoint and dedupes duplicates + const mergedFiles = [...moduleFiles, ...files]; + for (const entrypoint of entrypoints) { + for (const { extension, file, name } of mergedFiles) { + if (name !== entrypoint) { + continue; + } + + switch (extension) { + case '.js': + scripts.add(file); + break; + case '.css': + stylesheets.add(file); + break; + } + } + } + + // Find the head and body elements + const treeAdapter = parse5.treeAdapters.default; + const document = parse5.parse(params.inputContent, { + treeAdapter, + locationInfo: true, + }); + let headElement; + let bodyElement; + for (const docChild of document.childNodes) { + if (docChild.tagName === 'html') { + for (const htmlChild of docChild.childNodes) { + if (htmlChild.tagName === 'head') { + headElement = htmlChild; + } else if (htmlChild.tagName === 'body') { + bodyElement = htmlChild; + } + } + } + } + + if (!headElement || !bodyElement) { + throw new Error('Missing head and/or body elements'); + } + + // Determine script insertion point + let scriptInsertionPoint; + if (bodyElement.__location && bodyElement.__location.endTag) { + scriptInsertionPoint = bodyElement.__location.endTag.startOffset; + } else { + // Less accurate fallback + // parse5 4.x does not provide locations if malformed html is present + scriptInsertionPoint = params.inputContent.indexOf(''); + } + + let styleInsertionPoint; + if (headElement.__location && headElement.__location.endTag) { + styleInsertionPoint = headElement.__location.endTag.startOffset; + } else { + // Less accurate fallback + // parse5 4.x does not provide locations if malformed html is present + styleInsertionPoint = params.inputContent.indexOf(''); + } + + // Inject into the html + const indexSource = new rspack.sources.ReplaceSource( + new rspack.sources.RawSource(params.inputContent), + params.input + ); + + let scriptElements = ''; + for (const script of scripts) { + const attrs: { name: string; value: string | null }[] = [ + { name: 'src', value: (params.deployUrl || '') + script }, + ]; + + if (crossOrigin !== 'none') { + attrs.push({ name: 'crossorigin', value: crossOrigin }); + } + + // We want to include nomodule or module when a file is not common amongs all + // such as runtime.js + const scriptPredictor = ({ + file, + }: { + file: string; + name: string; + extension: string; + }): boolean => file === script; + if (!files.some(scriptPredictor)) { + // in some cases for differential loading file with the same name is avialable in both + // nomodule and module such as scripts.js + // we shall not add these attributes if that's the case + const isModuleType = moduleFiles.some(scriptPredictor); + + if (isModuleType) { + attrs.push({ name: 'type', value: 'module' }); + } else { + attrs.push({ name: 'defer', value: null }); + } + } else { + attrs.push({ name: 'type', value: 'module' }); + } + + if (params.sri) { + const content = loadOutputFile(script); + attrs.push(...this.generateSriAttributes(content)); + } + + const attributes = attrs + .map((attr) => + attr.value === null ? attr.name : `${attr.name}="${attr.value}"` + ) + .join(' '); + scriptElements += ``; + } + + indexSource.insert(scriptInsertionPoint, scriptElements); + + // Adjust base href if specified + if (typeof params.baseHref == 'string') { + let baseElement; + for (const headChild of headElement.childNodes) { + if (headChild.tagName === 'base') { + baseElement = headChild; + } + } + + const baseFragment = treeAdapter.createDocumentFragment(); + + if (!baseElement) { + baseElement = treeAdapter.createElement('base', undefined, [ + { name: 'href', value: params.baseHref }, + ]); + + treeAdapter.appendChild(baseFragment, baseElement); + indexSource.insert( + headElement.__location.startTag.endOffset, + parse5.serialize(baseFragment, { treeAdapter }) + ); + } else { + let hrefAttribute; + for (const attribute of baseElement.attrs) { + if (attribute.name === 'href') { + hrefAttribute = attribute; + } + } + if (hrefAttribute) { + hrefAttribute.value = params.baseHref; + } else { + baseElement.attrs.push({ name: 'href', value: params.baseHref }); + } + + treeAdapter.appendChild(baseFragment, baseElement); + indexSource.replace( + baseElement.__location.startOffset, + baseElement.__location.endOffset, + parse5.serialize(baseFragment, { treeAdapter }) + ); + } + } + + const styleElements = treeAdapter.createDocumentFragment(); + for (const stylesheet of stylesheets) { + const attrs = [ + { name: 'rel', value: 'stylesheet' }, + { name: 'href', value: (params.deployUrl || '') + stylesheet }, + ]; + + if (crossOrigin !== 'none') { + attrs.push({ name: 'crossorigin', value: crossOrigin }); + } + + if (params.sri) { + const content = loadOutputFile(stylesheet); + attrs.push(...this.generateSriAttributes(content)); + } + + const element = treeAdapter.createElement('link', undefined, attrs); + treeAdapter.appendChild(styleElements, element); + } + + indexSource.insert( + styleInsertionPoint, + parse5.serialize(styleElements, { treeAdapter }) + ); + + return indexSource; + } + + private generateSriAttributes(content: string) { + const algo = 'sha384'; + const hash = createHash(algo).update(content, 'utf8').digest('base64'); + + return [{ name: 'integrity', value: `${algo}-${hash}` }]; + } + + private filterAndMapBuildFiles( + files: EmittedFile[], + extensionFilter: string[] + ): { + file: string; + name: string; + extension: string; + }[] { + const filteredFiles: { + file: string; + name: string; + extension: string; + }[] = []; + + // This test excludes files generated by HMR (e.g. main.hot-update.js). + const hotUpdateAsset = /hot-update\.[cm]?js$/; + + for (const { file, name, extension, initial } of files) { + if ( + name && + initial && + extensionFilter.includes(extension) && + !hotUpdateAsset.test(file) + ) { + filteredFiles.push({ file, extension, name }); + } + } + return filteredFiles; + } +} diff --git a/packages/rspack/src/utils/webpack/interpolate-env-variables-to-index.ts b/packages/rspack/src/utils/webpack/interpolate-env-variables-to-index.ts new file mode 100644 index 0000000000..3adb6d840c --- /dev/null +++ b/packages/rspack/src/utils/webpack/interpolate-env-variables-to-index.ts @@ -0,0 +1,39 @@ +export function interpolateEnvironmentVariablesToIndex( + contents: string, + deployUrl?: string +): string { + const environmentVariables = getClientEnvironment(deployUrl || ''); + return interpolateEnvironmentVariables(contents, environmentVariables as any); +} + +const NX_PREFIX = /^NX_PUBLIC_/i; + +function isNxEnvironmentKey(x: string): boolean { + return NX_PREFIX.test(x); +} + +function getClientEnvironment(deployUrl: string) { + return Object.keys(process.env) + .filter(isNxEnvironmentKey) + .reduce( + (env, key) => { + env[key] = process.env[key]; + return env; + }, + { + NODE_ENV: process.env.NODE_ENV || 'development', + DEPLOY_URL: deployUrl || process.env.DEPLOY_URL || '', + } + ); +} + +function interpolateEnvironmentVariables( + documentContents: string, + environmentVariables: Record +): string { + let temp = documentContents; + for (const [key, value] of Object.entries(environmentVariables)) { + temp = temp.replace(new RegExp(`%${key}%`, 'g'), value); + } + return temp; +} diff --git a/packages/rspack/src/utils/webpack/normalize-entry.ts b/packages/rspack/src/utils/webpack/normalize-entry.ts new file mode 100644 index 0000000000..4966e21328 --- /dev/null +++ b/packages/rspack/src/utils/webpack/normalize-entry.ts @@ -0,0 +1,30 @@ +import { ExtraEntryPoint, NormalizedEntryPoint } from '../model'; + +export function normalizeExtraEntryPoints( + extraEntryPoints: ExtraEntryPoint[], + defaultBundleName: string +): NormalizedEntryPoint[] { + return extraEntryPoints.map((entry) => { + let normalizedEntry; + if (typeof entry === 'string') { + normalizedEntry = { + input: entry, + inject: true, + bundleName: defaultBundleName, + }; + } else { + const { inject = true, ...newEntry } = entry; + let bundleName; + + if (entry.bundleName) { + bundleName = entry.bundleName; + } else { + bundleName = defaultBundleName; + } + + normalizedEntry = { ...newEntry, bundleName }; + } + + return normalizedEntry; + }); +} diff --git a/packages/rspack/src/utils/webpack/package-chunk-sort.ts b/packages/rspack/src/utils/webpack/package-chunk-sort.ts new file mode 100644 index 0000000000..68d34b6594 --- /dev/null +++ b/packages/rspack/src/utils/webpack/package-chunk-sort.ts @@ -0,0 +1,53 @@ +import { ExtraEntryPoint } from '../model'; +import { normalizeExtraEntryPoints } from './normalize-entry'; + +export function generateEntryPoints(appConfig: { + styles: ExtraEntryPoint[]; + scripts: ExtraEntryPoint[]; +}) { + // Add all styles/scripts, except lazy-loaded ones. + const extraEntryPoints = ( + extraEntryPoints: ExtraEntryPoint[], + defaultBundleName: string + ): string[] => { + const entryPoints = normalizeExtraEntryPoints( + extraEntryPoints, + defaultBundleName + ).map((entry) => entry.bundleName); + + // remove duplicates + return [...new Set(entryPoints)]; + }; + + const styleEntryPoints = appConfig.styles.filter( + (style) => !(typeof style !== 'string' && !style.inject) + ); + const scriptEntryPoints = appConfig.scripts.filter( + (script) => !(typeof script !== 'string' && !script.inject) + ); + const entryPoints = [ + 'runtime', + 'polyfills', + 'sw-register', + ...extraEntryPoints(styleEntryPoints, 'styles'), + ...extraEntryPoints(scriptEntryPoints, 'scripts'), + 'vendor', + 'main', + ]; + + const duplicates = [ + ...new Set( + entryPoints.filter( + (x) => entryPoints.indexOf(x) !== entryPoints.lastIndexOf(x) + ) + ), + ]; + + if (duplicates.length > 0) { + throw new Error( + `Multiple bundles have been named the same: '${duplicates.join(`', '`)}'.` + ); + } + + return entryPoints; +} diff --git a/packages/rspack/src/utils/with-nx.ts b/packages/rspack/src/utils/with-nx.ts index c9db2bb035..df4f7456b4 100644 --- a/packages/rspack/src/utils/with-nx.ts +++ b/packages/rspack/src/utils/with-nx.ts @@ -40,6 +40,7 @@ export function withNx( targetName: context.targetName, configurationName: context.configurationName, projectGraph: context.projectGraph, + useLegacyHtmlPlugin: pluginOptions.useLegacyHtmlPlugin ?? false, }, config ); diff --git a/packages/rspack/src/utils/with-web.ts b/packages/rspack/src/utils/with-web.ts index f873a4b4e3..b328061cdb 100644 --- a/packages/rspack/src/utils/with-web.ts +++ b/packages/rspack/src/utils/with-web.ts @@ -20,6 +20,10 @@ export interface WithWebOptions { }; cssModules?: boolean; ssr?: boolean; + /** + * Use the legacy WriteIndexHtmlPlugin instead of the built-in HtmlRspackPlugin. + */ + useLegacyHtmlPlugin?: boolean; } const processed = new Set(); @@ -42,6 +46,7 @@ export function withWeb(pluginOptions: WithWebOptions = {}) { targetName: context.targetName, configurationName: context.configurationName, projectGraph: context.projectGraph, + useLegacyHtmlPlugin: pluginOptions.useLegacyHtmlPlugin ?? false, }, config );