Earlier this week I took a look at BoxLang's new rewriting feature (("URL Rewriting with BoxLang MiniServer")[https://www.raymondcamden.com/2025/08/11/url-rewriting-with-boxlang-miniserver]). It basically boils down to telling the miniserver app, "here is a file I want you to run on a 404", and given that you can write code for anything you would like, it's really flexible. I like this approach, but it got me thinking, what if BoxLang also supported a non-code based rewriting system, something where you can define paths, and rewrites, in a file? I took a stab at architecting such a feature and thought I'd share.
My Inspiration
My inspiration for this idea comes from Netlify's robust Redirect/Rewrite support which has multiple different features. It can map simple paths to one another and also map dynamic paths. It can even create simple proxies, letting you build apps that use client side code to APIs where you can't expose the keys in JavaScript. I took a look at the various options supported by Netlify and decided to try to tackle a subset of them as a proof of concept.
The File
My input file, rewrites.txt
, will be a simple text-based and tab-delimited set of input paths and rewrite destinations. Let's start simple:
/blog /news
# We renamed this in 1921
/pr /pressrelease
In the sample above, I've got two rewrite rules and a comment that should be ignored by the engine. In theory, any non-technical person can grok this and add or modify rules easily enough.
The Engine
And now for the engine itself. Again, starting simple, here is my rewriter.bxs
:
RW_FILE = './rewrites.txt';
if(!fileExists(RW_FILE)) return;
// load up rewrites and parse it (will cache)
function parseRWFile(contents) {
rules = [];
lines = contents.listToArray('#char(10)##char(13)#');
lines.each(l => {
parts = l.listToArray(char(9));
// must have 2, 3 is supported
if(parts.len() < 2) continue;
rule = {from:parts[1], to:parts[2]};
if(parts.len() === 3) rule.code = parts[3];
rules.append(rule)
});
return rules;
}
rules = parseRWFile(fileRead(RW_FILE));
rules.each(r => {
//straight x to y match
if(cgi.path_info == r.from) {
code = r.code?:301;
// special handling for 200
if(code !== "200") bx:location url=r.to statusCode=code;
else {
// will only work if you redirect to a specifc file, not a directory
// so ie: /something /somethingelse/index.bxm 200
bx:include template=r.to;
abort;
}
}
});
// handle 404
Up top, I simply default the filename to look for and do a quick check for its existence. Next I've got a basic file parsing utility that will go over every line in the input, split it by tabs, and ensure there's at least 2 values after the split. I use a third space to optionally let you set a status code for the redirect.
I iterate over the rules and begin with my first supported logic, a simple A=>B type match. If the cgi.path_info
matches a from value, I'm going to redirect the user. By default, this is done via bx:location
, which means the user will see the new URL. Typically this is what you want I'd say, and the user can bookmark the new location if they want. However, you may also want to 'blindly' do the redirect where the location doesn't change. That's when the 200 status code check comes in and I switch to simply including the new template. You'll note for that to work though you need to redirect to a specific file. Here's an example:
/blog2 /news/index.bxm 200
Splat!
I love "splat" - as a word it's just fun. That being said, one of the cooler Netlify redirect features is the idea of a wildcard match like so:
/prods/* /products/:splat
In this case, everything after the path /prods/
becomes the splat
value and the direct will include that. In my loop above, I added support like so:
// something/* to something/:splat
if(r.from.endsWith("*") && r.to.endsWith(":splat")) {
// first, does our current request match r.from
normalizedPart = r.from.replace("*","");
if(cgi.path_info.find(normalizedPart) === 1) {
splat = cgi.path_info.replace(normalizedPart,"");
newLocation = r.to.replace(":splat", splat);
bx:location url=newLocation;
}
}
This just boils down to looking for the asterisk and :splat
, and then doing string manipulation to handle the redirect.
This worked well but led to another problem.
Mapping URLs to Data
After I supported mapping /prods/foo
to /products/foo
, I realized this would only work if /products/foo/index.bxm
actually existed, which is fine of course. But what if I wanted to map to /products/index.bxm
and have the value, foo
, available to the code there?
I began by adding a new rule to my text file:
/products/* /products/:product
And then modified my rewriter code like so:
if(r.from.endsWith("*") && r.to.endsWith(":splat")) {
// first, does our current request match r.from
normalizedPart = r.from.replace("*","");
if(cgi.path_info.find(normalizedPart) === 1) {
splat = cgi.path_info.replace(normalizedPart,"");
newLocation = r.to.replace(":splat", splat);
bx:location url=newLocation;
}
} else if(r.from.endsWith("*") && r.to.reFind(":[a-zA-Z]+$")) {
// something/* to something/:name such that something/index.bxm is loaded with request.name == the value
matchedToken = r.to.mid(r.to.reFind(":[a-zA-Z]+$") + 1, r.to.len());
normalizedPart = r.from.replace("*","");
splat = cgi.path_info.replace(normalizedPart,"");
request[matchedToken] = splat;
normalizedLocation = r.to.replace(":#matchedToken#","");
bx:include template="#normalizedLocation#/index.bxm";
abort;
}
Now it handles cases where the end isn't :splat
and considers it a variable. This is stored in the request
scope and made available to the included document, which for now assumes index.bxm
. All in all it means this set of rules:
/prods/* /products/:splat
/products/* /products/:product
Will take a URL like /prods/catbox
and redirect to /products/catbox
in the browser while then loading products/index.bxm
and making a request variable, product
, contain the value catbox
. Whew. T
The Code
Ok, so as I said, this is all a proof of concept and not nearly as powerful as the system Netlify has in place, but it absolutely shows you could build something like that. Again, my idea here was to make it easier to both write rules for our app as well make it easier to read those rules later to understand behavior.
You can find the complete demo here, https://github.com/ortus-boxlang/bx-demos/tree/master/webapps/rewritedemo/filebased, and I've included both my text and BoxLang rewrite code below.
First, the text file:
/blog /news
/blog2 /news/index.bxm 200
# We renamed this in 1921
/pr /pressrelease
/prods/* /products/:splat
/products/* /products/:product
And now the engine:
RW_FILE = './rewrites.txt';
if(!fileExists(RW_FILE)) return;
// load up rewrites and parse it (will cache)
function parseRWFile(contents) {
rules = [];
lines = contents.listToArray('#char(10)##char(13)#');
lines.each(l => {
parts = l.listToArray(char(9));
// must have 2, 3 is supported
if(parts.len() < 2) continue;
rule = {from:parts[1], to:parts[2]};
if(parts.len() === 3) rule.code = parts[3];
rules.append(rule)
});
return rules;
}
rules = parseRWFile(fileRead(RW_FILE));
rules.each(r => {
//straight x to y match
if(cgi.path_info == r.from) {
code = r.code?:301;
// special handling for 200
if(code !== "200") bx:location url=r.to statusCode=code;
else {
// will only work if you redirect to a specifc file, not a directory
// so ie: /something /somethingelse/index.bxm 200
bx:include template=r.to;
abort;
}
}
// something/* to something/:splat
if(r.from.endsWith("*") && r.to.endsWith(":splat")) {
// first, does our current request match r.from
normalizedPart = r.from.replace("*","");
if(cgi.path_info.find(normalizedPart) === 1) {
splat = cgi.path_info.replace(normalizedPart,"");
newLocation = r.to.replace(":splat", splat);
bx:location url=newLocation;
}
} else if(r.from.endsWith("*") && r.to.reFind(":[a-zA-Z]+$")) {
// something/* to something/:name such that something/index.bxm is loaded with request.name == the value
matchedToken = r.to.mid(r.to.reFind(":[a-zA-Z]+$") + 1, r.to.len());
normalizedPart = r.from.replace("*","");
splat = cgi.path_info.replace(normalizedPart,"");
request[matchedToken] = splat;
normalizedLocation = r.to.replace(":#matchedToken#","");
bx:include template="#normalizedLocation#/index.bxm";
abort;
}
});
// handle 404
Photo by Susan Q Yin on Unsplash