- Published on
express-simple-proxy: an Express proxy with hooks and typed errors

- Authors

- Name
- Nadim Tuhin
- @nadimtuhin
On this page
I built this because I wanted one thing http-proxy-middleware doesn't give you out of the box: a typed error code for every upstream failure, plus a hook that can answer a request without calling upstream.
A backend that sits in front of another API ends up writing the same handler for every route: build the URL, copy the query string, forward the headers, call the upstream, map the failure. Most of that is not interesting, and the failure mapping is where handlers drift apart.
express-simple-proxy is a TypeScript middleware that does this once. Its README states the goal as proxying API requests to an upstream with no configuration beyond a base URL and a headers function, for API gateways and microservices. This post walks through the code, the test numbers, and the rough edges I found and fixed.
The problem: one handler per route
Without a helper, each route repeats the same steps. With the package, a route is one line:
import express from 'express';
import { createProxyController } from 'express-simple-proxy';
const app = express();
const proxy = createProxyController({
baseURL: 'https://api.example.com',
headers: (req) => ({ Authorization: req.headers.authorization ?? '' }),
});
app.get('/users/:id', proxy());
app.post('/users', proxy('/v2/users'));
proxy() with no argument forwards the incoming path as is. A path argument is a template: :id is filled from req.params by replaceUrlTemplate in src/utils.ts. The package is Express only and HTTP only. The README says to use http-proxy-middleware for WebSockets.
How it works

createProxyController in src/proxy.ts validates the config once, then returns a factory. Every request goes through four stops.
Build the payload. buildRequestPayload joins the base URL, path and query string, calls your headers(req), and sets the body. For POST, PUT, PATCH and DELETE it sends JSON, or rebuilds a FormData body when the request is multipart/form-data. Multer files in all three shapes (single, array, fields) are re-attached.
Run beforeRequest. The hook can edit the payload in place. It can also return an object with a numeric status, which ends the request without calling upstream:
if (beforeRequest) {
const hookResult = await beforeRequest(payload, reqWithFiles);
if (isShortCircuitResponse(hookResult)) {
const sc = hookResult;
if (sc.headers) {
res.set(sc.headers);
}
res.status(sc.status).json(sc.data);
await fireStats({
url: payload.url,
method: payload.method,
status: sc.status,
durationMs: Date.now() - startedAt,
source: 'short-circuit',
});
return;
}
}
This is the cache, rate limit or feature flag seam. The type guard in src/errors.ts is one line: a truthy value with a numeric status.
Call upstream. axiosProxyRequest uses axios with a 30 second default timeout (DEFAULT_TIMEOUT) and a 100 MB cap on both the request body and the upstream response. A commit from 2026-08-28 made non-JSON upstream responses use res.send() instead of res.json(), after an issue about corrupted binary payloads.
Report once. onResponse receives a ProxyStats object: URL, method, status, durationMs, optional response size, and a source of upstream or short-circuit. A statsFired flag in createProxyController guarantees one call per request, and a throwing callback is caught and logged, so metrics code cannot break a response.
Error classification

Axios errors come in two kinds: the server answered, or it did not. src/errors.ts handles both. If there was a response, the proxy keeps the upstream status and body, and marks 401 and 403 as UPSTREAM_AUTH. If there was none, a lookup table does the rest:
const NETWORK_ERROR_CODE_MAP: Record<string, ProxyErrorCode> = {
ECONNABORTED: 'UPSTREAM_TIMEOUT',
ETIMEDOUT: 'UPSTREAM_TIMEOUT',
ERR_CANCELED: 'UPSTREAM_TIMEOUT',
ECONNREFUSED: 'UPSTREAM_UNREACHABLE',
ENOTFOUND: 'UPSTREAM_UNREACHABLE',
ECONNRESET: 'UPSTREAM_UNREACHABLE',
};
Every network failure returns status 503 with the mapped code, or NETWORK_ERROR when the Node code is not in the table. A failure before the request leaves, such as a bad URL, becomes REQUEST_ERROR with status 500.
Two hooks sit on top. errorHandlerHook can rewrite the error object. errorHandler writes the response. If your custom handler throws, applyErrorHook logs it and falls back to defaultErrorHandler, so the client still gets a JSON body.
The results
These are the numbers before PR #40. Running the six test files on main before that PR with jest --coverage reported:
| Metric | Result |
|---|---|
| Tests | 183 passed, 0 failed |
| Statement coverage | 95.21% |
| Branch coverage | 93.38% |
src/errors.ts | 100% on all four columns |
src/proxy.ts | 92.85% statements |
| Runtime dependencies | 2 (axios, form-data) |
| Commits | 96, 28 of them dependency bumps |
| GitHub stars | 4 |
| npm downloads, 30 days | 24 (2026-09-13 to 2026-10-12) |
The README badge says 93% coverage. The coverage report says 95.21% for statements and 93.38% for branches, so the badge has drifted from the code. The lines proxy.ts does not cover are the request setup error branch, the two failure paths in applyErrorHook, and the development logging line.
The first commit and 1.0.0 landed on 2025-07-15. Releases 1.1.0 to 1.1.3 came on 2026-05-19, and npm lists 2.0.2 on 2026-05-21. The last commit on main before PR #40 is dated 2026-10-13.
What I fixed after writing this
These came from reading the source, README and release history, not from bug reports. On 2026-10-14 I merged one PR that fixed four of them.
- Setup errors skipped the error pipeline. If your
headers(req)function threw, the error went to Express throughnext(error)and never reachederrorHandleroronResponse. Header-building errors now reach the error handler. - Hook failures counted as upstream. When
beforeRequestthrew, the stats still saidsource: 'upstream'. There is now a'hook'source. - The development log printed credentials. With
NODE_ENV=development, the curl line logged every header, includingAuthorization, and the request body. It now redacts credentials and drops the body. - README Quick Start did not run. Example 1 passed only
baseURL, butvalidateConfigthrows unlessheadersis a function. The quick start is fixed.
One rough edge is still open: version drift. package.json on main says 1.1.3, git tag stops at v1.1.0, and npm has 2.0.2. I published from my local machine without tagging or pushing the version bump.
Found another one? Open an issue on the repo.
Resources
Related posts
- opencode-9router: model discovery for OpenCode from a 9Router gateway
An OpenCode plugin that asks a 9Router gateway for its models at startup, fills in limits and capabilities, and caches the list. v1.0.0, 26 tests after a six-fix PR.
- devutils: 33 browser-only dev tools and what the README overstated
devutils runs JSON, JWT, SQL and Base64 tools in the browser with no network calls. I checked the README against the code: the privacy claim holds, the 35+ tool count did not.
- pmharden: auditing npm, pnpm and yarn config for secrets
pmharden audits package manager config, scans for plaintext tokens and flags risky global packages in one command. A walkthrough of the code, the 45 tests and the rough edges I still see.