From a413635dd33c2bd4c5aa44a3d1e7227bcd15de81 Mon Sep 17 00:00:00 2001 From: Nima Taheri Date: Sun, 19 Oct 2025 21:10:38 -0700 Subject: [PATCH 01/11] conf: ignore vscode & idea IDE files conf: ignore package-lock.json --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 5148e52..7c8234f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# IDE +.vscode +.idea + # Logs logs *.log @@ -29,6 +33,7 @@ build/Release # Dependency directories node_modules jspm_packages +package-lock.json # Optional npm cache directory .npm From fbc1d4d8680422603b74577e9c3ee98300ef88bc Mon Sep 17 00:00:00 2001 From: Nima Taheri Date: Sun, 19 Oct 2025 21:10:53 -0700 Subject: [PATCH 02/11] conf: ensure nodejs 24.x --- .tool-versions | 1 + 1 file changed, 1 insertion(+) create mode 100644 .tool-versions diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..45f1ca6 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 24.10.0 From 966dc28e2b74455fd6c3a217cf8d6d6a64c0ea57 Mon Sep 17 00:00:00 2001 From: Nima Taheri Date: Sun, 19 Oct 2025 21:11:39 -0700 Subject: [PATCH 03/11] conf: add package.json for type-hints and prettier --- package.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 package.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..c7e7d44 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "redwood", + "version": "1.0.0", + "description": "Redirect network requests for assets to test production services with local changes.", + "private": true, + "type": "module", + "scripts": { + "test": "node --test", + "test:watch": "node --test --watch" + }, + "devDependencies": { + "@types/chrome": "^0.0.270", + "@types/node": "^24.8.1", + "prettier": "^3.6.2" + } +} + From ea4066d032f727e357ccbbff27217702380fe813 Mon Sep 17 00:00:00 2001 From: Nima Taheri Date: Sun, 19 Oct 2025 21:11:55 -0700 Subject: [PATCH 04/11] conf: basic prettier config --- .prettierrc | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..722a0c9 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "bracketSpacing": true, + "arrowParens": "always" +} + From c37b4f685b0b1ed8f3060b3c8f9f1d1f64c463bc Mon Sep 17 00:00:00 2001 From: Nima Taheri Date: Sun, 19 Oct 2025 21:12:57 -0700 Subject: [PATCH 05/11] feat: add a regex util to be able to write webpack-style patterns instead of regex --- regex_util.js | 194 +++++++++++++++++++++++++++++++++++++++++++++ regex_util.test.js | 112 ++++++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 regex_util.js create mode 100644 regex_util.test.js diff --git a/regex_util.js b/regex_util.js new file mode 100644 index 0000000..f80ccc3 --- /dev/null +++ b/regex_util.js @@ -0,0 +1,194 @@ +export const defaultPlaceholders = { + // to match any character + '*': /.*/, + // to match a segment in a URL so any character except '/' e.g. 'production' + segment: /[^\/]+/, + // to match a hex e.g. '672e65e9296a5864b92ae860' + hex: /[a-fA-F0-9]+/, + // to match all filename characters e.g. 'test.js' + // Matches all valid characters for a Unix filename (excluding '/' and null char) + filename: /[^~)('!*<>:;,?"|/\\]+/, + // to match an extension e.g. 'js' + alphanumeric: /[a-zA-Z0-9]+/, +}; + +/** + * A registry for placeholders used in the search and replacement patterns, that keeps track of the index of the capture group generated for each placeholder to be used in the replacement pattern. + * (e.g. `[segment#1]` will generate capture group 1, `[segment#2]` will generate capture group 2, etc.) + */ +class PlaceholderRegistry { + constructor() { + this.nameToBackRef = new Map(); + this.backRefCounter = 0; + this.nameToUsageCounter = new Map(); + } + + registerCaptureGroup(name, id) { + this.backRefCounter++; + + if (!this.nameToBackRef[name]) { + this.nameToBackRef[name] = {}; + } + + if (id !== undefined) { + this.nameToBackRef[name][id] = this.backRefCounter; + return; + } + + if (!this.nameToBackRef[name].orderedUsage) { + this.nameToBackRef[name].orderedUsage = []; + } + this.nameToBackRef[name].orderedUsage.push(this.backRefCounter); + } + + isRegistered(name) { + return !!this.nameToBackRef[name]; + } + + /** + * Returns the index of the capture group generated for the placeholder. + * + * @param {string} name - The name of the placeholder. + * @param {string} id - The id of the placeholder. + * @returns {number} The index of the capture group. + */ + getBackReference(name, id) { + if (!this.nameToBackRef[name]) { + throw new Error(`Placeholder '${name}' is not registered!`); + } + if (id !== undefined) { + const value = this.nameToBackRef[name][id]; + if (value === undefined) { + throw new Error(`Placeholder '${name}' with id '${id}' has not been used in search pattern!`); + } + return value; + } else { + const usageOrder = this.nameToBackRef[name].orderedUsage; + if (usageOrder === undefined) { + throw new Error(`Placeholder '${name}' has never been used without an id in search pattern!`); + } + const count = this.nameToUsageCounter.get(name) || 0; + this.nameToUsageCounter.set(name, count + 1); + const value = usageOrder[count]; + if (value === undefined) { + throw new Error( + `Placeholder '${name}' has been used ${usageOrder.length} time(s) in search pattern but used at least ${ + count + 1 + } times in replacement pattern!` + ); + } + return value; + } + } +} + +/** + * Replaces all occurrences of a regex in a string with a replacement function. + * + * @param {string} str - The string to replace. + * @param {RegExp} regex - The regex to match. + * @param {Function} replaceLiteralFn (optional) - The function to transform the text around the occurrence of the regex. + * @param {Function} replaceFn - The function to transform the regex match object (RegExpExecArray) into a replacement string. + * @returns {string} The replaced string. + */ +function replaceAll(str, regex, replaceLiteralFn, replaceFn) { + let match; + let from = 0; + let output = ''; + replaceLiteralFn = replaceLiteralFn || ((str) => str); + + // replace all occurrences of the regex in the string + while ((match = regex.exec(str)) !== null) { + output += replaceLiteralFn(str.slice(from, match.index)); + output += replaceFn(match); + from = match.index + match[0].length; + } + if (from < str.length) { + output += replaceLiteralFn(str.slice(from)); + } + + return output; +} + +/** + * Creates a pair of regular expressions for search and replacement using placeholders similar to webpack configurations. + * + * This utility function allows you to define search and replacement patterns containing placeholders + * (such as `[segment]`, `[name]`, `[hash]`, or custom ones) which will be translated into + * capture groups internally. The `env` parameter can be used to pass custom + * values or RegExp objects for specific placeholders. A set of default placeholder patterns are also available + * as `defaultPlaceholders`. + * + * Placeholders with an id (e.g. `[segment#1]/[segment#2]/[segment#3]/test.js`) can be used to match the same placeholder multiple times in the same pattern and used in + * the replacement pattern with different order (e.g. `[segment#3]/[segment#2]/test.js`). + * If order of repeated placeholders in search pattern is the same as in replacement pattern, + * you can simply use the placeholder without an id (e.g. `[segment]/[segment]/test.js` in search pattern and `[segment]/[segment]/main.json` in replacement pattern). + * + * @param {string} searchPattern - The pattern to match, using placeholders in square brackets. + * Example: 'https://google.com/[env]/test.js' + * @param {string} replacePattern - The pattern to generate a replacement, also using placeholders. + * Example: 'https://google.com/[env]/main.json' + * @param {Object} [env={}] - An object that maps placeholder names to values. Values can + * be strings (literal match) or RegExp objects (pattern match). + * Example: { env: `defaultPlaceholders.segment` } + * @param {Object} [options={}] - Additional configuration options. + * @param {string} [options.backReferenceSymbol='$'] - The symbol to use for backreferences in the replacement string (e.g., '$' or '\'). + * @param {Function} [options.regexEscape=RegExp.escape] - Escape function used to escape literal text in patterns. + * + * @returns {Object} An object containing: + * - searchRegex {string}: The string RegExp pattern for searching (e.g. 'https://google\\.com/(.*)/test\\.js') + * - replaceRegex {string}: The corresponding replacement pattern with group references (e.g. 'https://google.com/$1/main.json') + * + * @throws {Error} If placeholders in the replacement pattern do not match those defined in the search pattern. + * + * @example + * const {searchRegex, replaceRegex} = createReplaceRegex( + * 'https://google.com/[env]/test.js', + * 'https://google.com/[env]/main.json', + * {env: `defaultPlaceholders.segment`} + * ); + * // Results in: + * // searchRegex: '^https://google\.com/([^/]+)/test\.js$' + * // replaceRegex: 'https://google.com/$1/main.json' + */ +export function createReplaceRegex(searchPattern, replacePattern, env = {}, options = {}) { + // the symbol to use to back reference captured groups in regular expressions + // (e.g. $1 = '$', \1 = '\', etc.) + options.backReferenceSymbol = options.backReferenceSymbol || '$'; + options.regexEscape = options.regexEscape || RegExp.escape; + // regex to match [name] or [name#id] + // group 1: name + // group 2 (optional): id + const placeholderRegex = /\[([^\]#]+)(?:#([^\]#]+))?]/g; + const placeholderRegistry = new PlaceholderRegistry(); + + let searchRegex = replaceAll(searchPattern, placeholderRegex, options.regexEscape, ([expr, name, id]) => { + let value = expr; + if (env[name]) { + if (typeof env[name] === 'string') { + value = `(${options.regexEscape(env[name])})`; + } else if (env[name] instanceof RegExp) { + value = `(${env[name].source})`; + } else { + throw new Error(`Invalid placeholder value: ${name}. Must be a string or a RegExp.`); + } + } else if (defaultPlaceholders[name]) { + value = `(${defaultPlaceholders[name].source})`; + } else { + throw new Error(`Unknown placeholder: ${expr}`); + } + + placeholderRegistry.registerCaptureGroup(name, id); + return value; + }); + searchRegex = `^${searchRegex}$`; + + const replaceRegex = replaceAll(replacePattern, placeholderRegex, null, ([, name, id]) => { + if (!placeholderRegistry.isRegistered(name) && typeof env[name] === 'string') { + return env[name]; + } + return `${options.backReferenceSymbol}${placeholderRegistry.getBackReference(name, id)}`; + }); + + return { searchRegex, replaceRegex }; +} diff --git a/regex_util.test.js b/regex_util.test.js new file mode 100644 index 0000000..1e5da27 --- /dev/null +++ b/regex_util.test.js @@ -0,0 +1,112 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createReplaceRegex, defaultPlaceholders } from './regex_util.js'; + +describe('substituteBuilder', () => { + test('should escape all characters', () => { + const { searchRegex } = createReplaceRegex('wss://google.com:8081/test.js?query=value', ''); + + assert(new RegExp(searchRegex).test('wss://google.com:8081/test.js?query=value')); + assert(!new RegExp(searchRegex).test('wss://googleXcom:8081/test.js?query=value')); + assert(!new RegExp(searchRegex).test('wss://google.com:8081/test.j?query=value')); + }); + + test('should use env variables both in regex and replacement', () => { + const { searchRegex, replaceRegex } = createReplaceRegex( + 'wss://[subdomain].google.com:[port]/test.js', + 'ws://[replacement_website]/main.json', + { + subdomain: 'clou?d', + port: '8081', + replacement_website: 'localhost:8080', + } + ); + + assert(new RegExp(searchRegex).test('wss://clou?d.google.com:8081/test.js')); + assert.equal( + 'wss://clou?d.google.com:8081/test.js'.replace(new RegExp(searchRegex), replaceRegex), + 'ws://localhost:8080/main.json' + ); + }); + + test('should support env variables as placeholder', () => { + const { searchRegex, replaceRegex } = createReplaceRegex( + 'https://google.com/[env]/test.js', + 'https://google.com/[env]/main.json', + { env: defaultPlaceholders.segment } + ); + + assert(new RegExp(searchRegex).test('https://google.com/production/test.js')); + assert.equal( + 'https://google.com/production/test.js'.replace(new RegExp(searchRegex), replaceRegex), + 'https://google.com/production/main.json' + ); + }); + + test('should support one placeholder used twice', () => { + const { searchRegex, replaceRegex } = createReplaceRegex( + 'https://google.com/[segment]/[segment]/test.js', + 'https://yahoo.com/[segment]/[segment]/main.json' + ); + + assert.equal( + 'https://google.com/production/another_segment/test.js'.replace(new RegExp(searchRegex), replaceRegex), + 'https://yahoo.com/production/another_segment/main.json' + ); + }); + + test('should support placeholder with id', () => { + const { searchRegex, replaceRegex } = createReplaceRegex( + 'https://google.com/[segment#a]/[segment#1]/test.js', + 'https://yahoo.com/[segment#1]/[segment#a]/main.json' + ); + + assert.equal( + 'https://google.com/production/another_segment/test.js'.replace(new RegExp(searchRegex), replaceRegex), + 'https://yahoo.com/another_segment/production/main.json' + ); + }); + + test('should support segment placeholder', () => { + const { searchRegex, replaceRegex } = createReplaceRegex( + 'https://google.com/[segment]/test.js', + 'https://google.com/[segment]/main.json' + ); + + assert(new RegExp(searchRegex).test('https://google.com/production/test.js')); + assert(!new RegExp(searchRegex).test('https://google.com/production/another_segment/test.js')); + assert.equal( + 'https://google.com/production/test.js'.replace(new RegExp(searchRegex), replaceRegex), + 'https://google.com/production/main.json' + ); + }); + + test('should support hex placeholder', () => { + const { searchRegex, replaceRegex } = createReplaceRegex( + 'https://google.com/[hex]/test.js', + 'https://google.com/[hex]/main.json' + ); + + assert.equal( + 'https://google.com/672e65e9296a5864b92ae860/test.js'.replace(new RegExp(searchRegex), replaceRegex), + 'https://google.com/672e65e9296a5864b92ae860/main.json' + ); + assert(!new RegExp(searchRegex).test('https://google.com/672e65e9296a5864b92ae86G/test.js')); + }); + + test('should support * placeholder', () => { + const { searchRegex, replaceRegex } = createReplaceRegex( + 'https://google.com/[*]/test.js', + 'https://google.com/[*]/main.json' + ); + + assert.equal( + 'https://google.com/production/test.js'.replace(new RegExp(searchRegex), replaceRegex), + 'https://google.com/production/main.json' + ); + assert.equal( + 'https://google.com/production/another_segment/test.js'.replace(new RegExp(searchRegex), replaceRegex), + 'https://google.com/production/another_segment/main.json' + ); + }); +}); From 784551e7aaed6acbed480bb46d0d8831df9c28bb Mon Sep 17 00:00:00 2001 From: Nima Taheri Date: Sun, 19 Oct 2025 21:13:58 -0700 Subject: [PATCH 06/11] refactor: move rules to a separate file with tests --- rules.js | 119 ++++++++++++++++++++++++++++++++++++++++++++++ rules.test.js | 80 +++++++++++++++++++++++++++++++ service_worker.js | 119 +++++----------------------------------------- 3 files changed, 211 insertions(+), 107 deletions(-) create mode 100644 rules.js create mode 100644 rules.test.js diff --git a/rules.js b/rules.js new file mode 100644 index 0000000..d3077a5 --- /dev/null +++ b/rules.js @@ -0,0 +1,119 @@ +import { createReplaceRegex, defaultPlaceholders } from './regex_util.js'; + +export function createRules({ match, replace, wds, webServerPort, compressed }) { + let ruleIdCounter = 1; + + function createRegexReplace(searchPattern, replacePattern, useWDSBase) { + const replaceURL = new URL(replace); + const wdsBase = `${replaceURL.protocol}//${replaceURL.hostname}:${webServerPort}`; + const isWDS = wds === 'true'; + const isCompressed = compressed === 'true'; + + const env = { + name: defaultPlaceholders.segment, + hash: defaultPlaceholders.hex, + ext: defaultPlaceholders.alphanumeric, + orig_url: match, + repl_url: isWDS && useWDSBase ? wdsBase : replace, + min: isCompressed ? '.min' : '', + }; + + const options = { + backReferenceSymbol: '\\', + }; + + return createReplaceRegex(searchPattern, replacePattern, env, options); + } + + function createRedirectRule(searchPattern, replacePattern, useWDSBase, types) { + const { searchRegex, replaceRegex } = createRegexReplace(searchPattern, replacePattern, useWDSBase); + return { + id: ruleIdCounter++, + action: { + type: 'redirect', + redirect: { + regexSubstitution: replaceRegex, + }, + }, + condition: { + regexFilter: searchRegex, + resourceTypes: types, + }, + }; + } + + function createCorsRule(pattern, types) { + const { searchRegex } = createRegexReplace(pattern, '', true); + return { + id: ruleIdCounter++, + action: { + type: 'modifyHeaders', + responseHeaders: [ + { + header: 'Access-Control-Allow-Origin', + operation: 'set', + value: '*', + }, + { + header: 'Access-Control-Allow-Headers', + operation: 'set', + value: '*', + }, + ], + }, + condition: { + regexFilter: searchRegex, + resourceTypes: types, + }, + }; + } + + return [ + // JS chunks + createRedirectRule( + '[orig_url]/static/[segment]/[name].[hash].js', + '[repl_url]/static/[segment]/[name][min].js', + false, + ['script'] + ), + // JS + createRedirectRule( + '[orig_url]/static/[segment]/[segment]/[name].js', + '[repl_url]/static/[segment]/[segment]/[name][min].js', + false, + ['script'] + ), + // CSS chunks with single segment + createRedirectRule( + '[orig_url]/static/[segment]/[name].[hash].css', + '[repl_url]/static/[segment]/[name][min].css', + true, + ['stylesheet'] + ), + // CSS chunks with two segments + createRedirectRule( + '[orig_url]/static/[segment]/[segment]/[name].[hash].css', + '[repl_url]/static/[segment]/[segment]/[name][min].css', + true, + ['stylesheet'] + ), + // CORS for other resources + createCorsRule('[repl_url][*]', [ + 'csp_report', + 'font', + 'image', + 'main_frame', + 'media', + 'object', + 'other', + 'ping', + 'script', + 'stylesheet', + 'sub_frame', + 'webbundle', + 'websocket', + 'webtransport', + 'xmlhttprequest', + ]), + ]; +} diff --git a/rules.test.js b/rules.test.js new file mode 100644 index 0000000..82099cf --- /dev/null +++ b/rules.test.js @@ -0,0 +1,80 @@ +import { createRules } from './rules.js'; +import { describe, test } from 'node:test'; +import assert from 'node:assert'; + +/** + * Helper function to test if a URL matches and redirects correctly + * according to Chrome's declarativeNetRequest rules + */ +function testRedirect(rules, originalUrl) { + for (const rule of rules) { + if (rule.action.type !== 'redirect') continue; + + const regexFilter = rule.condition.regexFilter; + // Convert Chrome's regex format to JavaScript RegExp + // Chrome uses ^ and $ implicitly, and uses \1, \2 for back references + const regexSubstitution = rule.action.redirect.regexSubstitution.replaceAll(/\\(\d+)/g, '$$$1'); + + // Apply the substitution + const jsRegex = new RegExp(regexFilter); + if (jsRegex.test(originalUrl)) { + return originalUrl.replace(jsRegex, regexSubstitution); + } + } + + // No rule matched + return null; +} + +describe('DEV redirections', () => { + const rules = createRules({ + match: 'https://assets-dev.mongodb-cdn.com/mms', + replace: 'http://localhost:8081', + wds: 'true', + webServerPort: 8081, + compressed: 'false', + }); + + test('CSS chunk with hash should redirect to minified version', () => { + const result = testRedirect( + rules, + 'https://assets-dev.mongodb-cdn.com/mms/static/dist/bem-components.f5bb320780.css' + ); + assert.strictEqual(result, 'http://localhost:8081/static/dist/bem-components.css'); + }); + + test('JS chunk with hash should redirect to minified version', () => { + const result = testRedirect( + rules, + 'https://assets-dev.mongodb-cdn.com/mms/static/dist/runtime.ebe475bf44114144d0de.js' + ); + assert.strictEqual(result, 'http://localhost:8081/static/dist/runtime.js'); + }); + + test('JS file with two segments should redirect', () => { + const result = testRedirect( + rules, + 'https://assets-dev.mongodb-cdn.com/mms/static/dist/main/ui-access-list-edit-page.js' + ); + assert.strictEqual(result, 'http://localhost:8081/static/dist/main/ui-access-list-edit-page.js'); + }); + + test('HMR hot-update JS file should redirect', () => { + const result = testRedirect( + rules, + 'https://assets-dev.mongodb-cdn.com/mms/static/dist/packages_ai-models_components_UsagePage_tsx.2b6a17246155ab151602.hot-update.js' + ); + assert.strictEqual( + result, + 'http://localhost:8081/static/dist/packages_ai-models_components_UsagePage_tsx.2b6a17246155ab151602.hot-update.js' + ); + }); + + test('HMR hot-update JSON file should redirect', () => { + const result = testRedirect( + rules, + 'https://assets-dev.mongodb-cdn.com/mms/static/dist/main.2b6a17246155ab151602.hot-update.json' + ); + assert.strictEqual(result, 'http://localhost:8081/static/dist/main.2b6a17246155ab151602.hot-update.json'); + }); +}); diff --git a/service_worker.js b/service_worker.js index 2445f9f..6711af3 100644 --- a/service_worker.js +++ b/service_worker.js @@ -1,119 +1,24 @@ +import { createRules } from './rules.js'; + chrome.storage.sync .get({ - match: "https://my-source.com", - replace: "http://localhost:8080", + match: 'https://my-source.com', + replace: 'http://localhost:8080', wds: true, webServerPort: 8080, - compressed: false + compressed: false, }) - .then(async ({ match, replace, wds, webServerPort, compressed }) => { - const replaceURL = new URL(replace); - const likelyWebServerURL = `${replaceURL.protocol}//${replaceURL.hostname}:${webServerPort}`; - - const isWDS = wds === "true"; - const isCompressed = compressed === "true"; - const matchForRegex = match.replaceAll(/\./g, '\\.'); - const likelyWebServerURLForRegex = likelyWebServerURL.replaceAll(/\./g, '\\.'); - - const genRegex = `^${matchForRegex}.*\/static\/([a-z]+)\/([^/]*)\\.[a-fA-F0-9]+\\..*$`; - const appAssetRegex = `^${matchForRegex}.*\/static\/([a-z]+)\/([a-z]+)\/(.*)\\.[a-fA-F0-9]+\\..*$` - const appAssetNoHashRegex = `^${matchForRegex}.*\/static\/([a-z]+)\/([a-z]+)\/(.*)\\..*$` + .then(async (config) => { + // Uncomment for seeing matches (not a lot of info). + chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((info) => { + console.log(JSON.stringify(info)); + }); const oldRules = await chrome.declarativeNetRequest.getDynamicRules(); const oldRuleIds = oldRules.map((rule) => rule.id); - - // Uncomment for seeing matches (not a lot of info). - // chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((info) => { - // console.log(JSON.stringify(info)) - // }) - - const addMin = (type) => { - return isCompressed ? '.min' : ''; - } - const selectURLForRegexSub = (type) => { - return (isWDS && type === 'css') ? likelyWebServerURL : replace; - } - const makeGenAssetRegexSub = (type) => { - return `${selectURLForRegexSub(type)}/static/\\1/\\2${addMin(type)}.${type}` - } - const makeAppAssetRegexSub = (type) => { - return `${selectURLForRegexSub(type)}/static/\\1/\\2/\\3${addMin(type)}.${type}` - } - const makeAppAssetNoHashRegexSub = (type) => { - // min will show up in group 3, if needed. - return `${selectURLForRegexSub(type)}/static/\\1/\\2/\\3.${type}` - } - const cssAssetRegexSub = `${replace}/static/assets/css/\\1${addMin('css')}.css` - + const newRules = createRules(config); chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: oldRuleIds, - addRules: [ - // JS - { - id: 1, - action: { - type: "redirect", - "redirect": { regexSubstitution: makeGenAssetRegexSub('js'), } - }, - "condition": { - regexFilter: genRegex, - "resourceTypes": ["script"] - } - }, - // JS chunk redirected-to hashes - { - id: 2, - action: { - type: "redirect", - "redirect": { regexSubstitution: makeAppAssetNoHashRegexSub('js'), } - }, - "condition": { - regexFilter: appAssetNoHashRegex, - "resourceTypes": ["script"] - } - }, - - // CSS - { - id: 3, - action: { - type: "redirect", - redirect: { - regexSubstitution: makeGenAssetRegexSub('css'), - }, - }, - condition: { - regexFilter: genRegex, - resourceTypes: ["stylesheet"], - }, - }, - { - id: 4, - action: { - type: "redirect", - redirect: { - regexSubstitution: makeAppAssetRegexSub('css'), - }, - }, - condition: { - regexFilter: appAssetRegex, - resourceTypes: ["stylesheet"], - }, - }, - { - id: 5, - action: { - type: "modifyHeaders", - responseHeaders: [ - { header: "Access-Control-Allow-Origin", operation: "set", value: "*" }, - { header: "Access-Control-Allow-Headers", operation: "set", value: "*" }, - ], - }, - condition: { - urlFilter: isWDS ? likelyWebServerURL : replace, - resourceTypes: ["main_frame", "sub_frame", "stylesheet", "script", "image", "font", "object", "xmlhttprequest", "ping", "csp_report", "media", "websocket", "webtransport", "webbundle", "other"], - }, - }, - ], + addRules: newRules, }); }); From dde11da7fd8b40103c385faefe70d99b449374e4 Mon Sep 17 00:00:00 2001 From: Nima Taheri Date: Sun, 19 Oct 2025 21:15:23 -0700 Subject: [PATCH 07/11] feat: add redirect rule for webpack hot-updates --- rules.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/rules.js b/rules.js index d3077a5..e81f33a 100644 --- a/rules.js +++ b/rules.js @@ -69,6 +69,13 @@ export function createRules({ match, replace, wds, webServerPort, compressed }) } return [ + // HMR updates + createRedirectRule( + '[orig_url]/static/[segment]/[name].[hash].hot-update.[ext]', + '[repl_url]/static/[segment]/[name].[hash].hot-update.[ext]', + true, + ['script', 'stylesheet', 'other', 'xmlhttprequest'] + ), // JS chunks createRedirectRule( '[orig_url]/static/[segment]/[name].[hash].js', From c1336eb4d8a8b551fdba51c6fe30a04807bed4da Mon Sep 17 00:00:00 2001 From: Nima Taheri Date: Sun, 19 Oct 2025 21:16:12 -0700 Subject: [PATCH 08/11] feat: include "other" to js redirects sometimes browser recognizes these js files as "xml" for some reason --- rules.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rules.js b/rules.js index e81f33a..08320d7 100644 --- a/rules.js +++ b/rules.js @@ -88,7 +88,7 @@ export function createRules({ match, replace, wds, webServerPort, compressed }) '[orig_url]/static/[segment]/[segment]/[name].js', '[repl_url]/static/[segment]/[segment]/[name][min].js', false, - ['script'] + ['script', 'other'] ), // CSS chunks with single segment createRedirectRule( From e0b67988f54e6bb0b9361052eceb63e4bdd3f785 Mon Sep 17 00:00:00 2001 From: Nima Taheri Date: Sun, 19 Oct 2025 21:18:07 -0700 Subject: [PATCH 09/11] feat: add mongo-cdn presets to options dialog feat: use checkbox for compressed and wds checkbox --- options.html | 88 +++++++++++++++++++++++++++++++++++++----------- options.js | 94 ++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 137 insertions(+), 45 deletions(-) diff --git a/options.html b/options.html index 38ddb1f..8827eba 100644 --- a/options.html +++ b/options.html @@ -1,27 +1,75 @@ - redwood - - + redwood + + -
- - - - - -
- -
-
+
+
+ MongoCDN Presets: + + + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
diff --git a/options.js b/options.js index 3fd93e2..60bdb73 100644 --- a/options.js +++ b/options.js @@ -1,28 +1,72 @@ +setInputs = (items) => { + document.querySelector('input[name=match]').value = items.match; + document.querySelector('input[name=replace]').value = items.replace; + document.querySelector('input[name=wds]').checked = items.wds; + document.querySelector('input[name=webServerPort]').value = items.webServerPort; + document.querySelector('input[name=compressed]').checked = items.compressed; +}; + +setPreset = (event) => { + const presets = { + Development: { + match: 'https://assets-dev.mongodb-cdn.com/mms', + replace: 'http://localhost:8081', + wds: true, + webServerPort: 8081, + compressed: false, + }, + QA: { + match: 'https://assets-qa.mongodb-cdn.com/mms', + replace: 'http://localhost:8081', + wds: true, + webServerPort: 8081, + compressed: false, + }, + Production: { + match: 'https://assets.mongodb-cdn.com/mms', + replace: 'http://localhost:8081', + wds: true, + webServerPort: 8081, + compressed: false, + }, + }; + + setInputs(presets[event.target.value]); +}; + document.addEventListener('DOMContentLoaded', () => { - // Use default value color = 'red' and likesColor = true. - chrome.storage.sync.get({ - match: 'https://example.com', - replace: 'http://127.0.0.1:80', - wds: true, - webServerPort: 8080, - compressed: false - }, items => { - document.querySelector('input[name=match]').value = items.match - document.querySelector('input[name=replace]').value = items.replace - document.querySelector('input[name=wds]').value = items.wds - document.querySelector('input[name=webServerPort]').value = items.webServerPort - document.querySelector('input[name=compressed]').value = items.compressed - }) + // Use default value color = 'red' and likesColor = true. + chrome.storage.sync.get( + { + match: 'https://example.com', + replace: 'http://127.0.0.1:80', + wds: true, + webServerPort: 8080, + compressed: false, + }, + (items) => { + setInputs(items); + } + ); + + const getInput = (name) => document.querySelector(`input[name=${name}]`).value; + const getCheckbox = (name) => document.querySelector(`input[name=${name}]`).checked; - const getInput = name => document.querySelector(`input[name=${name}]`).value + document.querySelectorAll('input[name=preset]').forEach((button) => { + button.addEventListener('click', setPreset); + }); - document.querySelector('button[name=save]').addEventListener('click', () => { - chrome.storage.sync.set({ - match: getInput('match'), - replace: getInput('replace'), - wds: getInput('wds'), - webServerPort: getInput('webServerPort'), - compressed: getInput('compressed') - }, chrome.runtime.reload) - }) -}) + document.querySelector('button[name=save]').addEventListener('click', () => { + chrome.storage.sync.set( + { + match: getInput('match'), + replace: getInput('replace'), + wds: getCheckbox('wds'), + webServerPort: getInput('webServerPort'), + compressed: getCheckbox('compressed'), + }, + chrome.runtime.reload + ); + window.close(); + }); +}); From b39ad058b6939b1580dcccfbcb3340fb643ece6b Mon Sep 17 00:00:00 2001 From: Nima Taheri Date: Sun, 26 Oct 2025 21:32:18 -0700 Subject: [PATCH 10/11] processed review feedbacks --- manifest.json | 3 +- options.html | 35 ++---- options.js | 59 ++-------- regex_simplifier.js | 182 ++++++++++++++++++++++++++++++ regex_simplifier.test.js | 236 +++++++++++++++++++++++++++++++++++++++ regex_util.js | 194 -------------------------------- regex_util.test.js | 112 ------------------- rules.js | 198 ++++++++++++++++---------------- rules.test.js | 11 +- service_worker.js | 9 +- 10 files changed, 548 insertions(+), 491 deletions(-) create mode 100644 regex_simplifier.js create mode 100644 regex_simplifier.test.js delete mode 100644 regex_util.js delete mode 100644 regex_util.test.js diff --git a/manifest.json b/manifest.json index 14804d7..6cfcf1e 100644 --- a/manifest.json +++ b/manifest.json @@ -13,8 +13,7 @@ "declarativeNetRequestWithHostAccess", "webRequest" ], - "host_permissions": ["*://*/*"], - "optional_host_permissions": ["*://*/*"], + "host_permissions": ["*://*/*"], "options_ui": { "page": "options.html", "browser_style": true diff --git a/options.html b/options.html index 8827eba..e023be0 100644 --- a/options.html +++ b/options.html @@ -9,7 +9,6 @@ label { display: block; - margin-bottom: 5px; } label > span { @@ -37,35 +36,15 @@
- MongoCDN Presets: - - - + Redirect MongoCDN assets from: + + +

- -
- -
- -
- -
-