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
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"
+}
+
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
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 38ddb1f..e023be0 100644
--- a/options.html
+++ b/options.html
@@ -1,27 +1,54 @@
- redwood
-
-
+ redwood
+
+
-
-
-
-
-
-
-
-
+
+
+ Redirect MongoCDN assets from:
+
+
+
+
+
+
+
+
diff --git a/options.js b/options.js
index 3fd93e2..ceaab28 100644
--- a/options.js
+++ b/options.js
@@ -1,28 +1,37 @@
+setInputs = (items) => {
+ document.querySelector('input[name=redirect-mongo-cdn-development]').checked = items.redirectMongoCdnDevelopment;
+ document.querySelector('input[name=redirect-mongo-cdn-qa]').checked = items.redirectMongoCdnQa;
+ document.querySelector('input[name=redirect-mongo-cdn-production]').checked = items.redirectMongoCdnProduction;
+ document.querySelector('input[name=local-assets-url]').value = items.localAssetsUrl;
+};
+
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(
+ {
+ redirectMongoCdnDevelopment: true,
+ redirectMongoCdnQa: false,
+ redirectMongoCdnProduction: false,
+ localAssetsUrl: 'http://localhost:8081',
+ },
+ (items) => {
+ setInputs(items);
+ }
+ );
- const getInput = name => document.querySelector(`input[name=${name}]`).value
+ const getInput = (name) => document.querySelector(`input[name=${name}]`).value;
+ const getCheckbox = (name) => document.querySelector(`input[name=${name}]`).checked;
- 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(
+ {
+ redirectMongoCdnDevelopment: getCheckbox('redirect-mongo-cdn-development'),
+ redirectMongoCdnQa: getCheckbox('redirect-mongo-cdn-qa'),
+ redirectMongoCdnProduction: getCheckbox('redirect-mongo-cdn-production'),
+ localAssetsUrl: getInput('local-assets-url'),
+ },
+ chrome.runtime.reload
+ );
+ window.close();
+ });
+});
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"
+ }
+}
+
diff --git a/regex_simplifier.js b/regex_simplifier.js
new file mode 100644
index 0000000..ea7614b
--- /dev/null
+++ b/regex_simplifier.js
@@ -0,0 +1,182 @@
+/**
+ * A registry for named capture groups that tracks their numeric indices.
+ */
+class NamedGroupRegistry {
+ constructor() {
+ this.nameToNumber = new Map();
+ }
+
+ /**
+ * Registers a named capture group with its position number.
+ * @param {string} name - The name of the capture group.
+ * @param {number} position - The position/number of this capture group.
+ * @throws {Error} If the named group is already registered.
+ */
+ registerNamedGroup(name, position) {
+ if (this.nameToNumber.has(name)) {
+ throw new Error(`Named capture group '${name}' is already defined!`);
+ }
+ this.nameToNumber.set(name, position);
+ }
+
+ /**
+ * Returns the numeric index for a named capture group.
+ * @param {string} name - The name of the capture group.
+ * @returns {number} The numeric index.
+ * @throws {Error} If the named group is not registered.
+ */
+ getGroupNumber(name) {
+ if (!this.nameToNumber.has(name)) {
+ throw new Error(`Named capture group '${name}' is not defined in the search pattern!`);
+ }
+ return this.nameToNumber.get(name);
+ }
+}
+
+/**
+ * Converts named capture groups to numbered capture groups.
+ *
+ * This function takes a regex pattern with .NET-style named capture groups ((?...))
+ * and a replacement pattern with JavaScript-style named references (${name}), and converts
+ * them to numbered capture groups and backreferences.
+ *
+ * @param {string} searchRegex - The regex pattern with (?...) syntax.
+ * @param {string} replaceRegex - The replacement pattern with ${name} syntax.
+ * @param {Object} [options={}] - Configuration options.
+ * @param {string} [options.backReferenceSymbol='\\'] - Symbol for backreferences ('\\' or '$').
+ *
+ * @returns {Object} An object containing:
+ * - searchRegex {string}: The regex pattern with numbered groups.
+ * - replaceRegex {string}: The replacement pattern with numbered backreferences.
+ *
+ * @throws {Error} If duplicate named groups are found or undefined groups are referenced.
+ *
+ * @example
+ * const result = simplifyNamedGroups(
+ * '(?https?)://(?[^/]+)/(?.*)',
+ * '${protocol}://localhost/${path}',
+ * { backReferenceSymbol: '\\' }
+ * );
+ * // Result:
+ * // searchRegex: '(https?)://([^/]+)/(.*)'
+ * // replaceRegex: '\\1://localhost/\\3'
+ */
+export function simplifyNamedGroups(searchRegex, replaceRegex, options = {}) {
+ options.backReferenceSymbol = options.backReferenceSymbol || '\\';
+
+ const registry = new NamedGroupRegistry();
+
+ // Step 1: Scan the entire pattern to count ALL capture groups (both named and regular)
+ // and register named groups with their correct positions
+ let groupCounter = 0;
+ let pos = 0;
+
+ while (pos < searchRegex.length) {
+ const char = searchRegex[pos];
+
+ // Skip escaped characters
+ if (char === '\\') {
+ pos += 2;
+ continue;
+ }
+
+ // Check if this is a capture group
+ if (char === '(') {
+ // Check if it's a named group (?...)
+ if (searchRegex[pos + 1] === '?' && searchRegex[pos + 2] === '<') {
+ // Extract the name
+ const nameMatch = searchRegex.slice(pos).match(/^\(\?<([^>]+)>/);
+ if (nameMatch) {
+ groupCounter++;
+ registry.registerNamedGroup(nameMatch[1], groupCounter);
+ pos += nameMatch[0].length;
+ continue;
+ }
+ }
+ // Check if it's a non-capturing group (?:...)
+ else if (searchRegex[pos + 1] === '?' && searchRegex[pos + 2] === ':') {
+ pos++;
+ continue;
+ }
+ // Check for other non-capturing syntax like (?=...), (?!...), etc.
+ else if (searchRegex[pos + 1] === '?') {
+ pos++;
+ continue;
+ }
+ // It's a regular capturing group
+ else {
+ groupCounter++;
+ pos++;
+ continue;
+ }
+ }
+
+ pos++;
+ }
+
+ // Step 2: Convert search pattern - replace (?...) with (...)
+ let simplifiedSearch = searchRegex;
+ let match;
+ let offset = 0;
+
+ // Match named groups
+ const namedGroupStart = /\(\?<([^>]+)>/g;
+
+ while ((match = namedGroupStart.exec(searchRegex)) !== null) {
+ const groupName = match[1];
+ const startPos = match.index;
+ const contentStart = match.index + match[0].length;
+
+ // Find the matching closing parenthesis
+ let depth = 1;
+ let endPos = contentStart;
+
+ while (depth > 0 && endPos < searchRegex.length) {
+ const char = searchRegex[endPos];
+
+ if (char === '\\') {
+ // Skip escaped characters
+ endPos += 2;
+ continue;
+ }
+
+ if (char === '(') {
+ depth++;
+ } else if (char === ')') {
+ depth--;
+ }
+
+ endPos++;
+ }
+
+ if (depth !== 0) {
+ throw new Error(`Unmatched parentheses for named group '${groupName}'`);
+ }
+
+ // Replace (?...) with (...)
+ const groupContent = searchRegex.slice(contentStart, endPos - 1);
+ const replacement = `(${groupContent})`;
+
+ simplifiedSearch =
+ simplifiedSearch.slice(0, startPos + offset) +
+ replacement +
+ simplifiedSearch.slice(startPos + offset + (endPos - startPos));
+
+ // Adjust offset for the length difference
+ offset += replacement.length - (endPos - startPos);
+ }
+
+ // Step 3: Parse and convert replacement pattern
+ // Match ${name} references
+ const namedRefRegex = /\$\{([^}]+)\}/g;
+
+ const simplifiedReplace = replaceRegex.replace(namedRefRegex, (fullMatch, name) => {
+ const groupNumber = registry.getGroupNumber(name);
+ return `${options.backReferenceSymbol}${groupNumber}`;
+ });
+
+ return {
+ searchRegex: simplifiedSearch,
+ replaceRegex: simplifiedReplace,
+ };
+}
diff --git a/regex_simplifier.test.js b/regex_simplifier.test.js
new file mode 100644
index 0000000..a315fbd
--- /dev/null
+++ b/regex_simplifier.test.js
@@ -0,0 +1,236 @@
+import { describe, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { simplifyNamedGroups } from './regex_simplifier.js';
+
+describe('simplifyNamedGroups', () => {
+ describe('basic conversion', () => {
+ test('converts single named group', () => {
+ const result = simplifyNamedGroups('(?[a-z]+)', '${name}', {
+ backReferenceSymbol: '\\',
+ });
+
+ assert.equal(result.searchRegex, '([a-z]+)');
+ assert.equal(result.replaceRegex, '\\1');
+ });
+
+ test('converts multiple named groups', () => {
+ const result = simplifyNamedGroups(
+ '(?https?)://(?[^/]+)/(?.*)',
+ '${protocol}://localhost/${path}',
+ { backReferenceSymbol: '\\' }
+ );
+
+ assert.equal(result.searchRegex, '(https?)://([^/]+)/(.*)');
+ assert.equal(result.replaceRegex, '\\1://localhost/\\3');
+ });
+
+ test('handles groups in different order in replacement', () => {
+ const result = simplifyNamedGroups(
+ '(?\\w+)-(?\\w+)-(?\\w+)',
+ '${third}/${second}/${first}',
+ { backReferenceSymbol: '\\' }
+ );
+
+ assert.equal(result.searchRegex, '(\\w+)-(\\w+)-(\\w+)');
+ assert.equal(result.replaceRegex, '\\3/\\2/\\1');
+ });
+ });
+
+ describe('backreference symbols', () => {
+ test('uses backslash by default', () => {
+ const result = simplifyNamedGroups('(?\\w+)', '${test}');
+
+ assert.equal(result.replaceRegex, '\\1');
+ });
+
+ test('uses dollar sign when specified', () => {
+ const result = simplifyNamedGroups('(?\\w+)', '${test}', {
+ backReferenceSymbol: '$',
+ });
+
+ assert.equal(result.replaceRegex, '$1');
+ });
+ });
+
+ describe('nested and complex patterns', () => {
+ test('handles nested non-capturing groups', () => {
+ const result = simplifyNamedGroups(
+ '(?test(?:inner)?end)',
+ '${outer}',
+ { backReferenceSymbol: '\\' }
+ );
+
+ assert.equal(result.searchRegex, '(test(?:inner)?end)');
+ assert.equal(result.replaceRegex, '\\1');
+ });
+
+ test('handles multiple nested parentheses', () => {
+ const result = simplifyNamedGroups(
+ '(?a(b(c)d)e)',
+ '${group}',
+ { backReferenceSymbol: '\\' }
+ );
+
+ assert.equal(result.searchRegex, '(a(b(c)d)e)');
+ assert.equal(result.replaceRegex, '\\1');
+ });
+
+ test('handles escaped parentheses in groups', () => {
+ const result = simplifyNamedGroups(
+ '(?test\\(escaped\\))',
+ '${group}',
+ { backReferenceSymbol: '\\' }
+ );
+
+ assert.equal(result.searchRegex, '(test\\(escaped\\))');
+ assert.equal(result.replaceRegex, '\\1');
+ });
+
+ test('handles complex real-world pattern', () => {
+ const result = simplifyNamedGroups(
+ '^(?https?)://(?[^/]+)/static/(?[^/]+)/(?[^.]+)\\.(?[a-f0-9]+)\\.js$',
+ '${protocol}://localhost/static/${segment}/${name}.js',
+ { backReferenceSymbol: '\\' }
+ );
+
+ assert.equal(result.searchRegex, '^(https?)://([^/]+)/static/([^/]+)/([^.]+)\\.([a-f0-9]+)\\.js$');
+ assert.equal(result.replaceRegex, '\\1://localhost/static/\\3/\\4.js');
+ });
+ });
+
+ describe('replacement without all groups', () => {
+ test('allows referencing only some groups', () => {
+ const result = simplifyNamedGroups(
+ '(?\\w+)-(?\\w+)-(?\\w+)',
+ '${a}-${c}',
+ { backReferenceSymbol: '\\' }
+ );
+
+ assert.equal(result.searchRegex, '(\\w+)-(\\w+)-(\\w+)');
+ assert.equal(result.replaceRegex, '\\1-\\3');
+ });
+
+ test('allows replacement with no group references', () => {
+ const result = simplifyNamedGroups(
+ '(?\\w+)',
+ 'static-value',
+ { backReferenceSymbol: '\\' }
+ );
+
+ assert.equal(result.searchRegex, '(\\w+)');
+ assert.equal(result.replaceRegex, 'static-value');
+ });
+ });
+
+ describe('error cases', () => {
+ test('throws error for duplicate named group', () => {
+ assert.throws(
+ () => {
+ simplifyNamedGroups(
+ '(?\\w+)-(?\\d+)',
+ '${name}',
+ { backReferenceSymbol: '\\' }
+ );
+ },
+ { message: "Named capture group 'name' is already defined!" }
+ );
+ });
+
+ test('throws error for undefined group reference', () => {
+ assert.throws(
+ () => {
+ simplifyNamedGroups(
+ '(?\\w+)',
+ '${name}-${undefined}',
+ { backReferenceSymbol: '\\' }
+ );
+ },
+ { message: "Named capture group 'undefined' is not defined in the search pattern!" }
+ );
+ });
+
+ test('throws error for unmatched parentheses', () => {
+ assert.throws(
+ () => {
+ simplifyNamedGroups(
+ '(?\\w+',
+ '${name}',
+ { backReferenceSymbol: '\\' }
+ );
+ },
+ { message: "Unmatched parentheses for named group 'name'" }
+ );
+ });
+ });
+
+ describe('mixed content', () => {
+ test('handles literal text around groups', () => {
+ const result = simplifyNamedGroups(
+ 'prefix-(?\\w+)-suffix',
+ 'start-${middle}-end',
+ { backReferenceSymbol: '\\' }
+ );
+
+ assert.equal(result.searchRegex, 'prefix-(\\w+)-suffix');
+ assert.equal(result.replaceRegex, 'start-\\1-end');
+ });
+
+ test('handles special regex characters', () => {
+ const result = simplifyNamedGroups(
+ '^(?https?):\\/\\/(?[^/]+)\\/',
+ '${protocol}://new-${host}/',
+ { backReferenceSymbol: '\\' }
+ );
+
+ assert.equal(result.searchRegex, '^(https?):\\/\\/([^/]+)\\/');
+ assert.equal(result.replaceRegex, '\\1://new-\\2/');
+ });
+ });
+
+ describe('mixed numbered and named groups', () => {
+ test('handles named + numbered + named', () => {
+ const result = simplifyNamedGroups(
+ '(?\\w+)-([^-]+)-(?\\w+)',
+ '${first}/${third}',
+ { backReferenceSymbol: '\\' }
+ );
+
+ assert.equal(result.searchRegex, '(\\w+)-([^-]+)-(\\w+)');
+ assert.equal(result.replaceRegex, '\\1/\\3');
+ });
+
+ test('handles numbered + named', () => {
+ const result = simplifyNamedGroups(
+ '(\\d+)-(?\\w+)',
+ '${name}',
+ { backReferenceSymbol: '\\' }
+ );
+
+ assert.equal(result.searchRegex, '(\\d+)-(\\w+)');
+ assert.equal(result.replaceRegex, '\\2');
+ });
+
+ test('handles named + numbered (no reference to numbered)', () => {
+ const result = simplifyNamedGroups(
+ '(?https?)://([^/]+)/(?.*)',
+ '${protocol}://localhost/${path}',
+ { backReferenceSymbol: '\\' }
+ );
+
+ assert.equal(result.searchRegex, '(https?)://([^/]+)/(.*)');
+ assert.equal(result.replaceRegex, '\\1://localhost/\\3');
+ });
+
+ test('handles multiple numbered groups interspersed', () => {
+ const result = simplifyNamedGroups(
+ '(?\\w+)-(\\d+)-(?\\w+)-(\\d+)-(?\\w+)',
+ '${c}/${b}/${a}',
+ { backReferenceSymbol: '\\' }
+ );
+
+ assert.equal(result.searchRegex, '(\\w+)-(\\d+)-(\\w+)-(\\d+)-(\\w+)');
+ assert.equal(result.replaceRegex, '\\5/\\3/\\1');
+ });
+ });
+});
+
diff --git a/rules.js b/rules.js
new file mode 100644
index 0000000..fc0825b
--- /dev/null
+++ b/rules.js
@@ -0,0 +1,132 @@
+import {simplifyNamedGroups} from './regex_simplifier.js';
+
+/**
+ * Converts named capture groups to numbered capture groups because Chrome's declarativeNetRequest API only supports numbered capture groups.
+ * @param {Array} rules - The rules to simplify.
+ * @returns {Array} The simplified rules.
+ * @example
+ * const rules = [
+ * {
+ * condition: { regexFilter: '(?[a-z]+) is (?[0-9]+) years old' },
+ * action: { redirect: { regexSubstitution: '${name} is ${age} years old' } },
+ * },
+ * ];
+ * const simplifiedRules = simplifyNamedGroupsInRules(rules);
+ * console.log(simplifiedRules);
+ * // [
+ * // {
+ * // condition: { regexFilter: '([a-z]+) is ([0-9]+) years old' },
+ * // action: { redirect: { regexSubstitution: '\\1 is \\2 years old' } },
+ * // },
+ * // ];
+ */
+function simplifyNamedGroupsInRules(rules) {
+ const options = {
+ backReferenceSymbol: '\\',
+ };
+ for (const rule of rules) {
+ const search = rule.condition.regexFilter;
+ const replace = rule.action?.redirect?.regexSubstitution || '';
+ const { searchRegex, replaceRegex } = simplifyNamedGroups(search, replace, options);
+ rule.condition.regexFilter = searchRegex;
+ if (replace !== '') {
+ rule.action.redirect.regexSubstitution = replaceRegex;
+ }
+ }
+ return rules;
+}
+
+export function createRules({
+ redirectMongoCdnDevelopment,
+ redirectMongoCdnQa,
+ redirectMongoCdnProduction,
+ localAssetsUrl,
+}) {
+ let ruleIdCounter = 1;
+
+ const env = [
+ ...(redirectMongoCdnDevelopment ? ['-dev'] : []),
+ ...(redirectMongoCdnQa ? ['-qa'] : []),
+ ...(redirectMongoCdnProduction ? [''] : []),
+ ];
+
+ const p = {
+ dash_env: `(?${env.join('|')})`,
+ mms_prefix: '(?/mms)?',
+ path: '(?.+)',
+ name: '(?[^/]+?)',
+ opt_dot_hash: '(?\\.[0-9a-f]{10,})?',
+ opt_dot_min: '(?\\.min)?',
+ asset_ext: '(?js|css|json)',
+ };
+
+ return simplifyNamedGroupsInRules([
+ // redirect HMR updates
+ {
+ id: ruleIdCounter++,
+ condition: {
+ regexFilter: `^https:\/\/assets${p.dash_env}\\.mongodb-cdn\\.com${p.mms_prefix}\\/static\\/${p.path}\\/${p.name}\\.hot-update\\.${p.asset_ext}$`,
+ resourceTypes: ['script', 'stylesheet', 'other', 'xmlhttprequest'],
+ },
+ action: {
+ type: 'redirect',
+ redirect: {
+ regexSubstitution: localAssetsUrl + '/static/${path}/${name}.hot-update.${asset_ext}',
+ },
+ },
+ },
+ // redirect JS and CSS assets
+ {
+ id: ruleIdCounter++,
+ condition: {
+ regexFilter: `^https:\/\/assets${p.dash_env}\\.mongodb-cdn\\.com${p.mms_prefix}\\/static\\/${p.path}\\/${p.name}${p.opt_dot_hash}${p.opt_dot_min}\\.${p.asset_ext}$`,
+ resourceTypes: ['script', 'other', 'stylesheet'],
+ },
+ action: {
+ type: 'redirect',
+ redirect: {
+ regexSubstitution: localAssetsUrl + '/static/${path}/${name}.${asset_ext}',
+ },
+ },
+ },
+ // CORS for other resources
+ {
+ id: ruleIdCounter++,
+ condition: {
+ regexFilter: `^${localAssetsUrl}.*$`,
+ resourceTypes: [
+ 'csp_report',
+ 'font',
+ 'image',
+ 'main_frame',
+ 'media',
+ 'object',
+ 'other',
+ 'ping',
+ 'script',
+ 'stylesheet',
+ 'sub_frame',
+ 'webbundle',
+ 'websocket',
+ 'webtransport',
+ 'xmlhttprequest',
+ ],
+ },
+ action: {
+ type: 'modifyHeaders',
+ responseHeaders: [
+ {
+ header: 'Access-Control-Allow-Origin',
+ operation: 'set',
+ value: '*',
+ },
+ {
+ header: 'Access-Control-Allow-Headers',
+ operation: 'set',
+ value: '*',
+ },
+ ],
+ },
+ },
+ ]);
+}
diff --git a/rules.test.js b/rules.test.js
new file mode 100644
index 0000000..a77b076
--- /dev/null
+++ b/rules.test.js
@@ -0,0 +1,77 @@
+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({
+ redirectMongoCdnDevelopment: true,
+ localAssetsUrl: 'http://localhost:8081',
+ });
+
+ 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..564b017 100644
--- a/service_worker.js
+++ b/service_worker.js
@@ -1,119 +1,22 @@
+import { createRules } from './rules.js';
+
chrome.storage.sync
.get({
- match: "https://my-source.com",
- replace: "http://localhost:8080",
- wds: true,
- webServerPort: 8080,
- compressed: false
+ redirectMongoCdnDevelopment: true,
+ redirectMongoCdnQa: false,
+ redirectMongoCdnProduction: false,
+ localAssetsUrl: 'http://localhost:8081',
})
- .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) => {
+ 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,
});
});