Environment
httpxy@0.5.0
- Node.js (any recent)
Summary
When the proxy target has a non-root pathname (e.g. http://localhost:1080/maildev) and the incoming request URL is exactly /, the resulting outgoing path is /maildev — without the trailing slash. Any upstream that distinguishes /maildev from /maildev/ (e.g. Express apps mounted with app.use('/maildev', ...), which 301-redirect the no-slash form to the slash form) ends up in an infinite redirect loop when fronted by httpxy.
Where
src/_utils.ts (compiled dist/index.mjs:112-119):
function joinURL(base, path) {
if (!base || base === "/") return path || "/";
if (!path || path === "/") return base || "/"; // <-- drops the slash
const baseHasTrailing = base[base.length - 1] === "/";
const pathHasLeading = path[0] === "/";
if (baseHasTrailing && pathHasLeading) return base + path.slice(1);
if (!baseHasTrailing && !pathHasLeading) return base + "/" + path;
return base + path;
}
The early-return on line 2 conflates no path with root path. In URL semantics they aren't equivalent: /maildev and /maildev/ are different resources, and a properly written reverse proxy should preserve the trailing slash.
For comparison, the canonical http-proxy (which httpxy is a rewrite of) concatenates target.pathname + req.url directly, so /maildev + / → /maildev/. The current behavior is a divergence from that reference implementation.
Why this matters in practice
This is hit any time httpxy sits in front of a typical sub-path-mounted app and the user navigates to the index of that mount. h3's app.use(prefix, handler) strips the matched prefix from req.url, so a request to /maildev/ arrives at httpxy with req.url === '/' — exactly the case that triggers the bug. (Same effect for nitro's devProxy, which is what brought me here.)
A concrete real-world example is MailDev's middleware integration (https://deepwiki.com/maildev/maildev/3-usage-guide#4-middleware-integration), which sets a basePathname and 301-redirects the no-slash form.
Minimal repro
import { createProxyServer } from 'httpxy'
import { createServer } from 'node:http'
// Upstream that 301s /foo to /foo/ (mimics Express sub-path mount)
const upstream = createServer((req, res) => {
if (req.url === '/foo') { res.writeHead(301, { location: '/foo/' }); return res.end() }
if (req.url === '/foo/') { res.writeHead(200); return res.end('ok') }
res.writeHead(404); res.end()
}).listen(4001)
const proxy = createProxyServer({ target: 'http://localhost:4001/foo' })
createServer((req, res) => {
// simulate prefix-stripping middleware: incoming /foo/ has been rewritten to /
req.url = '/'
proxy.web(req, res)
}).listen(4000)
// curl -i http://localhost:4000/
// actual: 301 Location: /foo/ -> redirect loop
// expected: 200 ok
Suggested fix
Replace the lossy early-return with a concatenation that preserves the slash:
function joinURL(base, path) {
if (!base) return path || '/'
if (!path) return base
const baseHasTrailing = base[base.length - 1] === '/'
const pathHasLeading = path[0] === '/'
if (baseHasTrailing && pathHasLeading) return base + path.slice(1)
if (!baseHasTrailing && !pathHasLeading) return base + '/' + path
return base + path
}
With this, joinURL('/maildev', '/') returns '/maildev/', matching http-proxy and fixing the loop.
I'm happy to send a PR if the maintainers agree on the direction.
Environment
httpxy@0.5.0Summary
When the proxy target has a non-root pathname (e.g.
http://localhost:1080/maildev) and the incoming request URL is exactly/, the resulting outgoing path is/maildev— without the trailing slash. Any upstream that distinguishes/maildevfrom/maildev/(e.g. Express apps mounted withapp.use('/maildev', ...), which 301-redirect the no-slash form to the slash form) ends up in an infinite redirect loop when fronted by httpxy.Where
src/_utils.ts(compileddist/index.mjs:112-119):The early-return on line 2 conflates no path with root path. In URL semantics they aren't equivalent:
/maildevand/maildev/are different resources, and a properly written reverse proxy should preserve the trailing slash.For comparison, the canonical
http-proxy(which httpxy is a rewrite of) concatenatestarget.pathname + req.urldirectly, so/maildev+/→/maildev/. The current behavior is a divergence from that reference implementation.Why this matters in practice
This is hit any time httpxy sits in front of a typical sub-path-mounted app and the user navigates to the index of that mount. h3's
app.use(prefix, handler)strips the matched prefix fromreq.url, so a request to/maildev/arrives at httpxy withreq.url === '/'— exactly the case that triggers the bug. (Same effect for nitro'sdevProxy, which is what brought me here.)A concrete real-world example is MailDev's middleware integration (https://deepwiki.com/maildev/maildev/3-usage-guide#4-middleware-integration), which sets a
basePathnameand 301-redirects the no-slash form.Minimal repro
Suggested fix
Replace the lossy early-return with a concatenation that preserves the slash:
With this,
joinURL('/maildev', '/')returns'/maildev/', matchinghttp-proxyand fixing the loop.I'm happy to send a PR if the maintainers agree on the direction.