Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# IDE
.vscode
.idea

# Logs
logs
*.log
Expand Down Expand Up @@ -29,6 +33,7 @@ build/Release
# Dependency directories
node_modules
jspm_packages
package-lock.json

# Optional npm cache directory
.npm
Expand Down
11 changes: 11 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "always"
}

1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodejs 24.10.0
3 changes: 1 addition & 2 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@
"declarativeNetRequestWithHostAccess",
"webRequest"
],
"host_permissions": ["*://*/*"],
"optional_host_permissions": ["*://*/*"],
"host_permissions": ["*://*/*"],
"options_ui": {
"page": "options.html",
"browser_style": true
Expand Down
67 changes: 47 additions & 20 deletions options.html
Original file line number Diff line number Diff line change
@@ -1,27 +1,54 @@
<!DOCTYPE html>
<html>
<head>
<title>redwood</title>
<style>
body { padding: 10px; }
label { display: block; margin-bottom: 5px; }
label > span { display: inline-block; font-weight: bold; min-width: 120px; padding-right: 10px; text-align: right; }
label > input { width: 210px; }
footer { margin-top: 20px; text-align: center; }
button { cursor: pointer; }
</style>
<script src="options.js"></script>
<title>redwood</title>
<style>
body {
padding: 10px;
}

label {
display: block;
}

label > span {
display: inline-block;
font-weight: bold;
min-width: 120px;
padding-right: 10px;
}

label > input[type='text'] {
width: 400px;
}

footer {
margin-top: 20px;
text-align: center;
}

button {
cursor: pointer;
}
</style>
<script src="options.js"></script>
</head>
<body>
<main>
<label><span>Match URL prefix</span><input name="match"></label>
<label><span>Replace URL prefix</span><input name="replace"></label>
<label><span>Webpack Dev Server?</span><input name="wds"></label>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In retrospect, it's ridiculous that we left this as a text input for as long as we did

<label><span>Web Server Port (if using wds)</span><input name="webServerPort"></label>
<label><span>Compressed?</span><input name="compressed"></label>
<footer>
<button name="save">Save</button>
</footer>
</main>
<main>
<div>
<span>Redirect MongoCDN assets from:</span>
<label><input type="checkbox" name="redirect-mongo-cdn-development" value="Development"/> Development</label>
<label><input type="checkbox" name="redirect-mongo-cdn-qa" value="QA"/> QA</label>
<label><input type="checkbox" name="redirect-mongo-cdn-production" value="Production"/> Production</label>
</div>
<br/>
<label>
<span>To local assets URL:</span>
<input type="text" name="local-assets-url" id="local-assets-url">
</label>
<footer>
<button type="button" name="save">Save</button>
</footer>
</main>
</body>
</html>
59 changes: 34 additions & 25 deletions options.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
17 changes: 17 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}

182 changes: 182 additions & 0 deletions regex_simplifier.js
Original file line number Diff line number Diff line change
@@ -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 ((?<name>...))
* 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 (?<name>...) 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(
* '(?<protocol>https?)://(?<domain>[^/]+)/(?<path>.*)',
* '${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 (?<name>...)
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 (?<name>...) 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 (?<name>...) 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,
};
}
Loading