Published on

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

express-simple-proxy: an Express proxy with hooks and typed errors
Authors
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

Four steps in createProxyController: build the request payload, run the beforeRequest hook, call upstream with axios, report stats through onResponse

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

How network failures map: 401 and 403 pass through as UPSTREAM_AUTH; ECONNABORTED, ETIMEDOUT and ERR_CANCELED become UPSTREAM_TIMEOUT; ECONNREFUSED, ENOTFOUND and ECONNRESET become UPSTREAM_UNREACHABLE; any other code is NETWORK_ERROR with status 503

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:

MetricResult
Tests183 passed, 0 failed
Statement coverage95.21%
Branch coverage93.38%
src/errors.ts100% on all four columns
src/proxy.ts92.85% statements
Runtime dependencies2 (axios, form-data)
Commits96, 28 of them dependency bumps
GitHub stars4
npm downloads, 30 days24 (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.

  1. Setup errors skipped the error pipeline. If your headers(req) function threw, the error went to Express through next(error) and never reached errorHandler or onResponse. Header-building errors now reach the error handler.
  2. Hook failures counted as upstream. When beforeRequest threw, the stats still said source: 'upstream'. There is now a 'hook' source.
  3. The development log printed credentials. With NODE_ENV=development, the curl line logged every header, including Authorization, and the request body. It now redacts credentials and drops the body.
  4. README Quick Start did not run. Example 1 passed only baseURL, but validateConfig throws unless headers is 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