From 5f3ac04ff6627aee644ef603da92c52252c9f674 Mon Sep 17 00:00:00 2001 From: sunyiteng Date: Wed, 25 Jun 2025 20:01:17 +0800 Subject: [PATCH 1/5] chore: update --- .vscode/settings.json | 3 +- playground/rsbuild.config.ts | 56 ++++- src/AsyncChunkRetryPlugin.ts | 4 +- src/index.ts | 97 ++++---- src/runtime/asyncChunkRetry.ts | 85 ++++--- src/runtime/initialChunkRetry.ts | 111 ++++----- src/runtime/runtime.d.ts | 2 +- src/runtime/utils/findMatchingRule.ts | 36 +++ src/runtime/{ => utils}/urlCalculate.ts | 11 +- src/types.ts | 9 +- test/basic/multipleRules.test.ts | 284 ++++++++++++++++++++++++ 11 files changed, 544 insertions(+), 154 deletions(-) create mode 100644 src/runtime/utils/findMatchingRule.ts rename src/runtime/{ => utils}/urlCalculate.ts (88%) create mode 100644 test/basic/multipleRules.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 58f2f56..138b50e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,5 +14,6 @@ }, "[css]": { "editor.defaultFormatter": "biomejs.biome" - } + }, + "cSpell.words": ["Rsbuild"] } diff --git a/playground/rsbuild.config.ts b/playground/rsbuild.config.ts index 056f56a..b370f94 100644 --- a/playground/rsbuild.config.ts +++ b/playground/rsbuild.config.ts @@ -2,6 +2,60 @@ import { defineConfig } from '@rsbuild/core'; import { pluginReact } from '@rsbuild/plugin-react'; import { pluginAssetsRetry } from '../dist'; +export function createBlockMiddleware({ urlPrefix, blockNum, onBlock }) { + let counter = 0; + + return (req, res, next) => { + if (req.url?.startsWith(urlPrefix)) { + counter++; + // if blockNum is 3, 1 2 3 would be blocked, 4 would be passed + const isBlocked = counter % (blockNum + 1) !== 0; + + if (isBlocked && onBlock) { + onBlock({ + url: req.url, + count: counter, + timestamp: Date.now(), + }); + } + if (isBlocked) { + res.statusCode = 404; + } + res.setHeader('block-async', counter); + } + next(); + }; +} + export default defineConfig({ - plugins: [pluginAssetsRetry(), pluginReact()], + dev: { + setupMiddlewares: [ + (middlewares) => { + middlewares.unshift( + createBlockMiddleware({ + urlPrefix: '/static/js/async/src_AsyncCompTest_tsx.js', + blockNum: 3, + onBlock: ({ url, count }) => { + console.info(`Blocked ${url} for the ${count}th time`); + }, + }), + ); + }, + ], + }, + plugins: [ + pluginAssetsRetry({ + minify: true, + onRetry(context) { + console.info('onRetry', context); + }, + onSuccess(context) { + console.info('onSuccess', context); + }, + onFail(context) { + console.info('onFail', context); + }, + }), + pluginReact(), + ], }); diff --git a/src/AsyncChunkRetryPlugin.ts b/src/AsyncChunkRetryPlugin.ts index bc6ee5d..095ab22 100644 --- a/src/AsyncChunkRetryPlugin.ts +++ b/src/AsyncChunkRetryPlugin.ts @@ -50,10 +50,10 @@ class AsyncChunkRetryPlugin implements Rspack.RspackPluginInstance { readonly name = 'ASYNC_CHUNK_RETRY_PLUGIN'; readonly isRspack: boolean; readonly minify: boolean; - readonly runtimeOptions: NormalizedRuntimeRetryOptions; + readonly runtimeOptions: NormalizedRuntimeRetryOptions[]; constructor( - options: NormalizedRuntimeRetryOptions, + options: NormalizedRuntimeRetryOptions[], isRspack: boolean, minify: boolean, ) { diff --git a/src/index.ts b/src/index.ts index 9218ddc..6ae81fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { AsyncChunkRetryPlugin } from './AsyncChunkRetryPlugin.js'; import type { NormalizedRuntimeRetryOptions, PluginAssetsRetryOptions, + RuntimeRetryOptions, } from './types.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -22,38 +23,48 @@ export const PLUGIN_ASSETS_RETRY_NAME = 'rsbuild:assets-retry'; function getRuntimeOptions( userOptions: PluginAssetsRetryOptions, -): NormalizedRuntimeRetryOptions { - const { inlineScript, minify, ...restOptions } = userOptions; + defaultCrossOrigin: boolean | 'anonymous' | 'use-credentials', +): NormalizedRuntimeRetryOptions[] { + const { inlineScript, minify, ...runtimeOptions } = userOptions; const defaultOptions: NormalizedRuntimeRetryOptions = { max: 3, type: ['link', 'script', 'img'], domain: [], - crossOrigin: false, + crossOrigin: defaultCrossOrigin, delay: 0, addQuery: false, }; - const result: NormalizedRuntimeRetryOptions = { - ...defaultOptions, - ...restOptions, - }; + function normalizeOption( + options: RuntimeRetryOptions, + ): NormalizedRuntimeRetryOptions { + const result: NormalizedRuntimeRetryOptions = { + ...defaultOptions, + ...options, + }; - // Normalize config - if (!Array.isArray(result.type) || result.type.length === 0) { - result.type = defaultOptions.type; - } - if (!Array.isArray(result.domain) || result.domain.length === 0) { - result.domain = defaultOptions.domain; + // Normalize config + if (!Array.isArray(result.type) || result.type.length === 0) { + result.type = defaultOptions.type; + } + if (!Array.isArray(result.domain) || result.domain.length === 0) { + result.domain = defaultOptions.domain; + } + if (Array.isArray(result.domain)) { + result.domain = result.domain.filter(Boolean); + } + return result; } - if (Array.isArray(result.domain)) { - result.domain = result.domain.filter(Boolean); + if ('rules' in runtimeOptions) { + const result = runtimeOptions.rules.map((i) => normalizeOption(i)); + return result; } - return result; + return [normalizeOption(runtimeOptions)]; } async function getRetryCode( - runtimeOptions: NormalizedRuntimeRetryOptions, + runtimeOptions: NormalizedRuntimeRetryOptions[], minify: boolean, ): Promise { const filename = 'initialChunkRetry'; @@ -79,38 +90,30 @@ export const pluginAssetsRetry = ( return path.posix.join(distDir, `assets-retry.${PLUGIN_VERSION}.js`); }; - const normalizeOptions = ( + const getDefaultValueFromRsbuildConfig = ( config: NormalizedEnvironmentConfig, - ): PluginAssetsRetryOptions & { + ): { minify: boolean; crossorigin: boolean | 'anonymous' | 'use-credentials'; } => { - const options = { ...userOptions }; - - // options.crossOrigin should be same as html.crossorigin by default - if (options.crossOrigin === undefined) { - options.crossOrigin = config.html.crossorigin; - } - - if (options.minify === undefined) { - const minify = - typeof config.output.minify === 'boolean' - ? config.output.minify - : config.output.minify?.js; - options.minify = minify && config.mode === 'production'; - } - - return options as PluginAssetsRetryOptions & { - minify: boolean; - crossorigin: boolean | 'anonymous' | 'use-credentials'; + const minify = + typeof config.output.minify === 'boolean' + ? config.output.minify + : config.output.minify?.js; + + return { + crossorigin: config.html.crossorigin, + minify: Boolean(minify) && config.mode === 'production', }; }; if (inlineScript) { api.modifyHTMLTags(async ({ headTags, bodyTags }, { environment }) => { - const options = normalizeOptions(environment.config); - const runtimeOptions = getRuntimeOptions(options); - const code = await getRetryCode(runtimeOptions, options.minify); + const { minify, crossorigin } = getDefaultValueFromRsbuildConfig( + environment.config, + ); + const runtimeOptions = getRuntimeOptions(userOptions, crossorigin); + const code = await getRetryCode(runtimeOptions, minify); headTags.unshift({ tag: 'script', @@ -141,9 +144,11 @@ export const pluginAssetsRetry = ( { stage: 'additional' }, async ({ sources, compilation, environment }) => { const scriptPath = getScriptPath(environment); - const options = normalizeOptions(environment.config); - const runtimeOptions = getRuntimeOptions(options); - const code = await getRetryCode(runtimeOptions, options.minify); + const { crossorigin, minify } = getDefaultValueFromRsbuildConfig( + environment.config, + ); + const runtimeOptions = getRuntimeOptions(userOptions, crossorigin); + const code = await getRetryCode(runtimeOptions, minify); compilation.emitAsset(scriptPath, new sources.RawSource(code)); }, ); @@ -156,13 +161,13 @@ export const pluginAssetsRetry = ( return; } - const options = normalizeOptions(config); - const runtimeOptions = getRuntimeOptions(options); + const { crossorigin, minify } = getDefaultValueFromRsbuildConfig(config); + const runtimeOptions = getRuntimeOptions(userOptions, crossorigin); const isRspack = api.context.bundlerType === 'rspack'; chain .plugin('async-chunk-retry') - .use(AsyncChunkRetryPlugin, [runtimeOptions, isRspack, options.minify]); + .use(AsyncChunkRetryPlugin, [runtimeOptions, isRspack, minify]); }); }, }); diff --git a/src/runtime/asyncChunkRetry.ts b/src/runtime/asyncChunkRetry.ts index 37ef0a9..b210d23 100644 --- a/src/runtime/asyncChunkRetry.ts +++ b/src/runtime/asyncChunkRetry.ts @@ -1,10 +1,11 @@ import { ERROR_PREFIX } from './constants.js'; +import { findMatchingRule } from './utils/findMatchingRule.js'; import { findCurrentDomain, findNextDomain, getNextRetryUrl, getQueryFromUrl, -} from './urlCalculate.js'; +} from './utils/urlCalculate.js'; // rsbuild/runtime/async-chunk-retry type ChunkId = string; // e.g: src_AsyncCompTest_tsx @@ -18,6 +19,7 @@ type Retry = { originalScriptFilename: ChunkFilename; originalSrcUrl: ChunkSrcUrl; originalQuery: string; + rule: NormalizedRuntimeRetryOptions; }; type RetryCollector = Record>; @@ -28,7 +30,7 @@ type LoadScript = ( key: string, chunkId: ChunkId, ...args: unknown[] -) => void; +) => string; type LoadStyleSheet = (href: string, chunkId: ChunkId) => string; declare global { @@ -58,8 +60,7 @@ declare global { } // init retryCollector and nextRetry function -const config = __RETRY_OPTIONS__; -const maxRetries = config.max; +const rules = __RETRY_OPTIONS__; const retryCollector: RetryCollector = {}; const retryCssCollector: RetryCollector = {}; @@ -78,7 +79,7 @@ function getCurrentRetry( : retryCollector[chunkId]?.[existRetryTimes]; } -function initRetry(chunkId: string, isCssAsyncChunk: boolean): Retry { +function initRetry(chunkId: string, isCssAsyncChunk: boolean): Retry | null { const originalScriptFilename = isCssAsyncChunk ? originalGetCssFilename(chunkId) : originalGetChunkScriptFilename(chunkId); @@ -95,7 +96,11 @@ function initRetry(chunkId: string, isCssAsyncChunk: boolean): Retry { const originalQuery = getQueryFromUrl(originalSrcUrl); const existRetryTimes = 0; - const nextDomain = findCurrentDomain(originalSrcUrl, config); + const rule = findMatchingRule(originalSrcUrl, rules); + if (rule === null) { + return null; + } + const nextDomain = findCurrentDomain(originalSrcUrl, rule); return { nextDomain, @@ -105,11 +110,12 @@ function initRetry(chunkId: string, isCssAsyncChunk: boolean): Retry { nextDomain, existRetryTimes, originalQuery, - config, + rule, ), originalScriptFilename, originalSrcUrl, originalQuery, + rule, }; } @@ -117,22 +123,26 @@ function nextRetry( chunkId: string, existRetryTimes: number, isCssAsyncChunk: boolean, -): Retry { +): Retry | null { const currRetry = getCurrentRetry(chunkId, existRetryTimes, isCssAsyncChunk); - let nextRetry: Retry; + let nextRetry: Retry | null; const nextExistRetryTimes = existRetryTimes + 1; if (existRetryTimes === 0 || currRetry === undefined) { nextRetry = initRetry(chunkId, isCssAsyncChunk); + if (!nextRetry) { + return null; + } if (isCssAsyncChunk) { retryCssCollector[chunkId] = []; } else { retryCollector[chunkId] = []; } } else { - const { originalScriptFilename, originalSrcUrl, originalQuery } = currRetry; - const nextDomain = findNextDomain(currRetry.nextDomain, config); + const { originalScriptFilename, originalSrcUrl, originalQuery, rule } = + currRetry; + const nextDomain = findNextDomain(currRetry.nextDomain, rule); nextRetry = { nextDomain, @@ -142,12 +152,13 @@ function nextRetry( nextDomain, existRetryTimes, originalQuery, - config, + rule, ), originalScriptFilename, originalSrcUrl, originalQuery, + rule, }; } @@ -225,6 +236,7 @@ function ensureChunk(chunkId: string): Promise { let originalScriptFilename: string; let nextRetryUrl: string; let nextDomain: string; + let rule: NormalizedRuntimeRetryOptions; const isCssAsyncChunkLoadFailed = Boolean( error?.message?.includes('CSS chunk'), @@ -243,11 +255,17 @@ function ensureChunk(chunkId: string): Promise { existRetryTimes, isCssAsyncChunkLoadFailed, ); + if (!retryResult) { + throw error; + } originalScriptFilename = retryResult.originalScriptFilename; nextRetryUrl = retryResult.nextRetryUrl; nextDomain = retryResult.nextDomain; + rule = retryResult.rule; } catch (e) { - console.error(ERROR_PREFIX, 'failed to get nextRetryUrl', e); + if (e !== error) { + console.error(ERROR_PREFIX, 'failed to get nextRetryUrl', e); + } throw error; } @@ -261,44 +279,23 @@ function ensureChunk(chunkId: string): Promise { const context = createContext(existRetryTimes); - if (existRetryTimes >= maxRetries) { + if (existRetryTimes >= rule.max) { error.message = error.message?.includes('retries:') ? error.message - : `Loading chunk ${chunkId} from "${originalScriptFilename}" failed after ${maxRetries} retries: "${error.message}"`; - if (typeof config.onFail === 'function') { - config.onFail(context); - } - throw error; - } - - // Filter by config.test and config.domain - let tester = config.test; - if (tester) { - if (typeof tester === 'string') { - const regexp = new RegExp(tester); - tester = (str: string) => regexp.test(str); + : `Loading chunk ${chunkId} from "${originalScriptFilename}" failed after ${rule.max} retries: "${error.message}"`; + if (typeof rule.onFail === 'function') { + rule.onFail(context); } - - if (typeof tester !== 'function' || !tester(nextRetryUrl)) { - throw error; - } - } - - if ( - config.domain && - config.domain.length > 0 && - config.domain.indexOf(nextDomain) === -1 - ) { throw error; } // Start retry - if (typeof config.onRetry === 'function') { - config.onRetry(context); + if (typeof rule.onRetry === 'function') { + rule.onRetry(context); } const delayTime = - typeof config.delay === 'function' ? config.delay(context) : config.delay; + typeof rule.delay === 'function' ? rule.delay(context) : rule.delay; const delayPromise = delayTime > 0 @@ -313,16 +310,16 @@ function ensureChunk(chunkId: string): Promise { // at the end, callingCounter.count is 4 const isLastSuccessRetry = callingCounter?.count === existRetryTimesAll + 2; - if (typeof config.onSuccess === 'function' && isLastSuccessRetry) { + if (typeof rule.onSuccess === 'function' && isLastSuccessRetry) { const context = createContext(existRetryTimes + 1); - config.onSuccess(context); + rule.onSuccess(context); } return result; }); }); } -function loadScript() { +function loadScript(): string { // biome-ignore lint/style/noArguments: allowed const args = Array.prototype.slice.call(arguments) as Parameters; const retry = globalCurrRetrying[args[3]]; diff --git a/src/runtime/initialChunkRetry.ts b/src/runtime/initialChunkRetry.ts index f4176cd..25f0fff 100644 --- a/src/runtime/initialChunkRetry.ts +++ b/src/runtime/initialChunkRetry.ts @@ -1,18 +1,21 @@ // rsbuild/runtime/initial-chunk-retry import { ERROR_PREFIX } from './constants.js'; +import { findMatchingRule } from './utils/findMatchingRule.js'; import { findCurrentDomain, findNextDomain, getNextRetryUrl, getQueryFromUrl, -} from './urlCalculate.js'; +} from './utils/urlCalculate.js'; interface ScriptElementAttributes { url: string; times: number; - isAsync: boolean; originalQuery: string; - crossOrigin?: CrossOrigin | boolean; + ruleIndex: number; + + crossOrigin?: CrossOrigin | boolean; // script only + isAsync: boolean; // script only } const TAG_TYPE: { [propName: string]: new () => HTMLElement } = { @@ -35,24 +38,41 @@ function getRequestUrl(element: HTMLElement) { } function validateTargetInfo( - config: NormalizedRuntimeRetryOptions, + rules: NormalizedRuntimeRetryOptions[], e: Event, -): { target: HTMLElement; tagName: string; url: string } | false { +): + | { + target: HTMLElement; + tagName: string; + url: string; + rule: NormalizedRuntimeRetryOptions; + } + | false { const target: HTMLElement = e.target as HTMLElement; const tagName = target.tagName.toLocaleLowerCase(); - const allowTags = config.type; + const url = getRequestUrl(target); + if (!url) { + return false; + } + + const ruleIndex = Number(target.dataset.rbRuleI); + const rule = rules[ruleIndex] ?? findMatchingRule(url, rules); + if (!rule) { + return false; + } + + const allowTags = rule.type; if ( !tagName || allowTags.indexOf(tagName) === -1 || !TAG_TYPE[tagName] || - !(target instanceof TAG_TYPE[tagName]) || - !url + !(target instanceof TAG_TYPE[tagName]) ) { return false; } - return { target, tagName, url }; + return { target, tagName, url, rule }; } function createElement( @@ -69,6 +89,10 @@ function createElement( const originalQueryAttr = attributes.originalQuery ? `data-rb-original-query="${attributes.originalQuery}"` : ''; + + const ruleIndexAttr = + attributes.ruleIndex > 0 ? `data-rb-rule-i="${attributes.ruleIndex}"` : ''; + const isAsyncAttr = attributes.isAsync ? 'data-rb-async' : ''; if (origin instanceof HTMLScriptElement) { @@ -86,12 +110,15 @@ function createElement( if (attributes.originalQuery !== undefined) { script.dataset.rbOriginalQuery = attributes.originalQuery; } + if (attributes.ruleIndex > 0) { + script.dataset.rbRuleI = String(attributes.ruleIndex); + } return { element: script, str: // biome-ignore lint/style/useTemplate: use "" instead of script tag to avoid syntax error when inlining in html - `