htaccess-trace
v0.1.0
Published
Trace an Apache .htaccess rewrite from the command line: which RewriteRule fired, which RewriteCond failed, and what the URL became.
Maintainers
Readme
htaccess-trace
Your RewriteRule didn't fire and Apache won't tell you why.
htaccess-trace runs the rules on your machine and prints what happened to one URL, line by
line: which rule matched, which RewriteCond failed and what it was actually tested against,
what the path became after each pass, and where the request ended up.
$ npx htaccess-trace examples/wordpress.htaccess /blog/hello-world
Trace for /blog/hello-world
line 5: RewriteRule ^index\.php$ — no match
line 8: RewriteRule . — applied
line 6: PASS "/blog/hello-world" vs !-f
assumption: cannot stat '/blog/hello-world' in simulation; assuming -f is false
line 7: PASS "/blog/hello-world" vs !-d
assumption: cannot stat '/blog/hello-world' in simulation; assuming -d is false
path: /blog/hello-world -> /index.php
line 5: RewriteRule ^index\.php$ — applied
Result: internally rewritten to /index.php after 2 passes. The browser URL does not change.No server, no restart, no log to read backwards. Apache's own answer to this question is
LogLevel rewrite:trace8 plus a restart plus a log file, which you do not have on shared
hosting and do not want on production.
Run it
Node 16 or newer. Nothing else — no Apache, no PHP, no network access, nothing is uploaded.
$ npx htaccess-trace <file> <url>or straight from the repository, without npm:
$ npx github:theseusbuilds/htaccess-trace <file> <url>or clone and run it directly:
$ git clone https://github.com/theseusbuilds/htaccess-trace
$ cd htaccess-trace
$ ./bin/htaccess-trace.js examples/canonical-https-www.htaccess http://example.com/pricingThe URL argument takes what you would actually type: /blog/post,
example.com/blog/post, or https://example.com/blog/post?ref=x. A bare word with no dot is
treated as a path, not a hostname.
Lint a file in CI
--check skips the trace and reports only what is wrong with the file. It exits 1 if
anything is at error severity, so it drops into a pipeline as-is.
$ htaccess-trace --check examples/broken.htaccess
The file
warning line 5: Rewrite rules here are ignored by Apache: no RewriteEngine directive. Add "RewriteEngine On" above them.
info line 10: Flag "T=image/webp" is valid but not modelled by the simulator; the trace shows what Apache would do *ignoring* it.
warning line 10: Unknown RewriteRule flag "NOCSAE". Did you mean "NOCASE"?
warning line 13: R=404 is outside the 3xx redirect range; Apache treats non-3xx codes here as an error response, not a redirect.
error line 18: Invalid regular expression in RewriteRule pattern: Invalid regular expression: /^products/([0-9]+$/gu: Unterminated group
$ echo $?
1This is not a syntax checker — the parser already reports malformed lines. These are the mistakes that parse fine and then behave wrong on the server.
What it simulates, and what it does not
Stated here rather than discovered after it gives you a confident wrong answer.
Simulated. RewriteRule and RewriteCond in a per-directory (.htaccess) context: pattern
matching, backreferences, condition chaining with [OR], server variable expansion, the rewrite
loop and the ten-pass limit Apache imposes on it, and the flags L, END, NC, QSA, QSD,
R=, F and G.
Recognised, reported, not simulated. Every other flag. When your file uses one, the trace names it on the line rather than pretending it had no effect.
Not modelled. RewriteBase, RewriteMap, proxying ([P]), mod_alias (Redirect,
RedirectMatch), server-config context, and the merge of parent-directory .htaccess files.
These are parsed, so they do not break the file; they just do not participate.
Request headers are empty. A simulation has no incoming request, so %{HTTP_USER_AGENT},
%{HTTP_REFERER} and the rest expand to an empty string, and a condition on one is shown being
tested against "". It is printed rather than silently skipped, because a rule that fires only
for one browser should not look like a rule that never fires.
File tests are assumed false. RewriteCond %{REQUEST_FILENAME} !-f cannot be answered
without your filesystem. The simulator assumes the file does not exist and prints that
assumption on the line it affects. That is the right guess for a front controller and the wrong
guess if you are debugging a rule that depends on a file being there.
Patterns are compiled by JavaScript, not by PCRE. They agree on ordinary rewrite patterns
and disagree at the edges. Where a ruleset uses a construct JavaScript cannot compile — atomic
groups, possessive quantifiers, \A \z \Z, POSIX character classes — the trace prints a
warning above itself instead of guessing.
The same engine in three places
The parser, simulator and validator in src/core/ are the ones inside
.htaccess Pro, a JetBrains IDE plugin, and behind the
browser version of this tool. One implementation
compiled three ways, not three programs that agree today and drift next month. The plugin, the
web page and this CLI all compile that one directory, and a check refuses the release if any of
them stops doing so.
| | where | cost | |---|---|---| | htaccess-trace | your terminal, your CI | free, MIT | | browser version | theseusbuilds.dev/htaccess-tester/ | free, no account | | .htaccess Pro | IntelliJ IDEA, PhpStorm, WebStorm and the rest | free during early access |
The plugin adds what a terminal cannot: the trace runs against the file open in your editor, double-clicking a line of the trace jumps to that line of the file, and the validator's findings are highlighted in the file itself as you work on it.
Examples
examples/ holds four real-world rule sets — WordPress, Laravel, a canonical HTTPS+www
redirect, a legacy redirect table — and broken.htaccess, which is the file above.
$ ./bin/htaccess-trace.js examples/canonical-https-www.htaccess http://example.com/pricing
$ ./bin/htaccess-trace.js examples/laravel.htaccess /api/users/42
$ ./bin/htaccess-trace.js examples/legacy-redirects.htaccess /products/42
$ ./bin/htaccess-trace.js examples/legacy-redirects.htaccess /products/old-sku-1The last two are the same rule set and the same rule. One rewrites and one does not, because
^products/([0-9]+)/?$ wants digits and old-sku-1 is not digits. That is the entire genre of
bug this tool exists for, and the trace names the rule that declined rather than leaving you to
stare at the file.
Tests
./test.sh runs every example through the built CLI and diffs the result against the recorded
output in tests/expected/. It needs nothing but Node.
Source
src/core/ is the engine. src/cli/ is one file of argument parsing and printing around it.
dist/ is that source compiled to JavaScript and committed, so the tool runs with no toolchain
and npx works straight off a clone. Kotlin, compiled with Kotlin/JS.
Found a file it gets wrong?
That is the most useful thing you can send: open an issue with the rules and the URL, or email [email protected]. A file that traces wrong is a bug in the plugin too.
What gets fixed and what does not is written down in SUPPORT.md, so you know before you spend the time: wrong output and crashes are fixed, documented limits are limits.
Licence
MIT. See LICENSE.
Apache, mod_rewrite and JetBrains are trademarks of their respective owners. This project is not affiliated with or endorsed by the Apache Software Foundation or JetBrains s.r.o.
