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/README.md b/README.md index beb20f0..1ecf785 100644 --- a/README.md +++ b/README.md @@ -59,33 +59,43 @@ type AssetsRetryHookContext = { isAsyncChunk: boolean; }; -type AssetsRetryOptions = { +type RuntimeRetryOptions = { type?: string[]; domain?: string[]; max?: number; - test?: string | ((url: string) => boolean); + test?: string | RegExp | ((url: string) => boolean); crossOrigin?: boolean | 'anonymous' | 'use-credentials'; - inlineScript?: boolean; delay?: number | ((context: AssetsRetryHookContext) => number); onRetry?: (context: AssetsRetryHookContext) => void; onSuccess?: (context: AssetsRetryHookContext) => void; onFail?: (context: AssetsRetryHookContext) => void; }; + +type AssetsRetryOptions = + | ({ + inlineScript?: boolean; + minify?: boolean; + } & RuntimeRetryOptions) + | { + inlineScript?: boolean; + minify?: boolean; + rules: RuntimeRetryOptions[]; + }; ``` - **Default:** ```ts const defaultAssetsRetryOptions = { + max: 3, type: ['script', 'link', 'img'], domain: [], - max: 3, - test: '', crossOrigin: false, + test: '', delay: 0, - onRetry: () => {}, - onSuccess: () => {}, - onFail: () => {}, + addQuery: false, + inlineScript: true, + minify: rsbuildConfig.mode === 'production', }; ``` @@ -242,7 +252,7 @@ When set to `true`, `retry=${times}` will be added to the query when requesting, When you want to customize query, you can pass a function, for example: -- **Example:** All assets requested do not contain query: +- **Example 1:** All assets requested do not contain query: ```js pluginAssetsRetry({ @@ -254,7 +264,7 @@ pluginAssetsRetry({ }); ``` -- **Example:** If there is a query in some of the requested assets, you can read it with `originalQuery`: +- **Example 2:** If there is a query in some of the requested assets, you can read it with `originalQuery`: ```js pluginAssetsRetry({ @@ -325,6 +335,80 @@ pluginAssetsRetry({ }); ``` +### rules + +- **Type:** `RuntimeRetryOptions[]` +- **Default:** `undefined` + +Configure multiple retry rules with different options. Each rule will be evaluated in order, and the first matching rule will be used for retry logic. This is useful when you have different retry requirements for different types of assets or domains. + +When using `rules`, the plugin will: + +1. Check each rule in order by `test` `domain` `type` + +2. If the rule is matched, the rule's configuration will be used to retry + +3. If no rule is matched, the resource will not be retried + +Each rule supports all the same options as the top-level configuration, including `type`, `domain`, `max`, `test`, `crossOrigin`, `delay`, `onRetry`, `onSuccess`, and `onFail`. + +- **Example 1:** Different retry strategies for different CDNs: + +```js +pluginAssetsRetry({ + rules: [ + { + // Rule for primary CDN + test: /cdn1\.example\.com/, + domain: ['cdn1.example.com', 'cdn1-backup.example.com'], + max: 3, + delay: 1000, + }, + { + // Rule for secondary CDN with more retries + test: /cdn2\.example\.com/, + domain: ['cdn2.example.com', 'cdn2-backup.example.com'], + max: 5, + delay: 500, + }, + { + // Default rule for other assets + domain: ['default.example.com', 'default-backup.example.com'], + max: 2, + }, + ], +}); +``` + +- **Example 2:** Different retry strategies for different asset types: + +```js +pluginAssetsRetry({ + rules: [ + { + // Critical JavaScript files get more retries + type: ['script'], + // Or test: /\.js$/, + max: 5, + delay: 1000, + onFail: ({ url }) => console.error(`Critical JS failed: ${url}`), + }, + { + // CSS files get fewer retries + test: /\.css$/, + max: 2, + delay: 500, + }, + { + // Images get minimal retries + test: /\.(png|jpg|gif|svg)$/, + max: 1, + delay: 0, + }, + ], +}); +``` + ## Notes When you use Assets Retry plugin, the Rsbuild injects some runtime code into the HTML and [Rspack Runtime](https://rspack.dev/misc/glossary#runtime), then serializes the Assets Retry plugin config, inserting it into the runtime code. Therefore, you need to be aware of the following: diff --git a/README.zh-CN.md b/README.zh-CN.md index 73dda31..23c6d2d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -57,33 +57,43 @@ type AssetsRetryHookContext = { isAsyncChunk: boolean; }; -type AssetsRetryOptions = { +type RuntimeRetryOptions = { type?: string[]; domain?: string[]; max?: number; test?: string | ((url: string) => boolean); crossOrigin?: boolean | 'anonymous' | 'use-credentials'; - inlineScript?: boolean; delay?: number | ((context: AssetsRetryHookContext) => number); onRetry?: (context: AssetsRetryHookContext) => void; onSuccess?: (context: AssetsRetryHookContext) => void; onFail?: (context: AssetsRetryHookContext) => void; }; + +type AssetsRetryOptions = + | ({ + inlineScript?: boolean; + minify?: boolean; + } & RuntimeRetryOptions) + | { + inlineScript?: boolean; + minify?: boolean; + rules: RuntimeRetryOptions[]; + }; ``` - **默认值:** ```ts -const defaultOptions = { +const defaultAssetsRetryOptions = { + max: 3, type: ['script', 'link', 'img'], domain: [], - max: 3, - test: '', crossOrigin: false, + test: '', delay: 0, - onRetry: () => {}, - onSuccess: () => {}, - onFail: () => {}, + addQuery: false, + inlineScript: true, + minify: rsbuildConfig.mode === 'production', }; ``` @@ -240,7 +250,7 @@ type AddQuery = 当你想要自定义 query 时,可以传入一个函数,比如: -- **示例:** 请求的所有资源都不含 query: +- **示例 1:** 请求的所有资源都不含 query: ```js pluginAssetsRetry({ @@ -252,7 +262,7 @@ pluginAssetsRetry({ }); ``` -- **示例:** 当请求的某些资源中含有 query 时,可以使用 `originalQuery` 读取: +- **示例 2:** 当请求的某些资源中含有 query 时,可以使用 `originalQuery` 读取: ```js pluginAssetsRetry({ @@ -323,6 +333,80 @@ pluginAssetsRetry({ }); ``` +### rules + +- **类型:** `RuntimeRetryOptions[]` +- **默认值:** `undefined` + +配置多个重试规则,每个规则可以有不同的选项。规则会按顺序进行评估,第一个匹配的规则将用于重试逻辑。这在你对不同类型的资源或域名有不同的重试需求时非常有用。 + +使用 `rules` 时,插件会: + +1. 按顺序通过 `test` `domain` `type` 检查每个规则 + +2. 如果匹配到规则,会使用规则的配置进行重试 + +3. 如果没有匹配到规则,则不会重试该资源 + +每个规则支持与顶层配置相同的所有选项,包括 `type`、`domain`、`test`、`max`、`crossOrigin`、`delay`、`onRetry`、`onSuccess` 和 `onFail`。 + +- **示例 1:** 不同 CDN 的不同重试策略: + +```js +pluginAssetsRetry({ + rules: [ + { + // 主 CDN 的规则 + test: /cdn1\.example\.com/, + domain: ['cdn1.example.com', 'cdn1-backup.example.com'], + max: 3, + delay: 1000, + }, + { + // 次要 CDN 的规则,更多重试次数 + test: /cdn2\.example\.com/, + domain: ['cdn2.example.com', 'cdn2-backup.example.com'], + max: 5, + delay: 500, + }, + { + // 其他资源的默认规则 + domain: ['default.example.com', 'default-backup.example.com'], + max: 2, + }, + ], +}); +``` + +- **示例 2:** 不同资源类型的不同重试策略: + +```js +pluginAssetsRetry({ + rules: [ + { + // 关键 JavaScript 文件获得更多重试次数 + test: /\.js$/, + // 或者 type: ['script'], + max: 5, + delay: 1000, + onFail: ({ url }) => console.error(`关键 JS 失败: ${url}`), + }, + { + // CSS 文件获得较少的重试次数 + test: /\.css$/, + max: 2, + delay: 500, + }, + { + // 图片获得最少的重试次数 + test: /\.(png|jpg|gif|svg)$/, + max: 1, + delay: 0, + }, + ], +}); +``` + ## 注意事项 当你使用 Assets Retry 插件时,Rsbuild 会分别向 HTML 和 [Rspack Runtime](https://rspack.dev/zh/misc/glossary#runtime) 中注入运行时代码,并将 Assets Retry 插件配置的内容序列化后插入到这些代码中,因此你需要注意: 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..3dca38d 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,12 @@ function initRetry(chunkId: string, isCssAsyncChunk: boolean): Retry { const originalQuery = getQueryFromUrl(originalSrcUrl); const existRetryTimes = 0; - const nextDomain = findCurrentDomain(originalSrcUrl, config); + const tagName = isCssAsyncChunk ? 'link' : 'script'; + const rule = findMatchingRule(originalSrcUrl, tagName, rules); + if (!rule) { + return null; + } + const nextDomain = findCurrentDomain(originalSrcUrl, rule); return { nextDomain, @@ -105,11 +111,12 @@ function initRetry(chunkId: string, isCssAsyncChunk: boolean): Retry { nextDomain, existRetryTimes, originalQuery, - config, + rule, ), originalScriptFilename, originalSrcUrl, originalQuery, + rule, }; } @@ -117,22 +124,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 +153,13 @@ function nextRetry( nextDomain, existRetryTimes, originalQuery, - config, + rule, ), originalScriptFilename, originalSrcUrl, originalQuery, + rule, }; } @@ -225,6 +237,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 +256,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 +280,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 +311,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..180c02e 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,43 @@ 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; + ruleIndex: number; + } + | 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; + } + + let ruleIndex = Number(target.dataset.rbRuleI || '-1'); + const rule = rules[ruleIndex] || findMatchingRule(url, tagName, rules); + if (!rule) { + return false; + } + ruleIndex = rules.indexOf(rule); + + 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, ruleIndex }; } function createElement( @@ -69,6 +91,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 +112,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 - `