@thescaffold/ntx-apps-proxy
v0.2.55
Published
A reverse proxy app with two independent, opt-in modes per route:
Readme
@thescaffold/ntx-apps-proxy
A reverse proxy app with two independent, opt-in modes per route:
tcp(default) — routes matching connections to configured target servers before Fastify, Nest middleware, guards, or any route handler ever sees them. Routing decisions happen on the raw socket, off a cheap regex read of the HTTP request-line + Host header, not a full HTTP parse. See "Why TCP-level" below.http(opt-in viahttpProxy: true) — runs after Fastify/Nest have fully processed the request (all middleware, guards, interceptors, pipes applied, exactly as for any other route), then forwards it to the target and relays the response back through Nest's normal response pipeline, so it looks native to Fastify/Nest in every observable sense except that a different server produced the response. See "HTTP-mode routes" below.
Both modes share the same route-matching engine (match.host /
match.pathPrefix / match.pathRegex / match.methods) and the same
DB-override behavior — only how a matched route is actually forwarded
differs.
Why TCP-level
Any proxy implemented as Nest middleware/guard/interceptor runs after the
underlying HTTP server has already parsed the request. To genuinely act
before that, this app puts its own net.Server in front of everything —
the only socket bound to the public port. For each connection it peeks at
just enough of the initial bytes to read the method, path, and Host header,
then either:
- tunnels the raw socket to a matched target (
net.connect/tls.connect- bidirectional
pipe()— a blind L4 tunnel, no HTTP re-parsing), or
- bidirectional
- hands the socket to the local Nest/Fastify app in-process, via
httpServer.emit('connection', socket)— the same socket object, zero extra network hop — for anything that doesn't match a route.
Traffic that isn't a recognizable plaintext HTTP/1.x request line (a TLS handshake, an HTTP/2 preface, ...) fails the head-parse immediately and falls straight through to local handling, so this only ever actively diverts traffic it can confidently route.
Trade-off: routing is per connection, not per request — a keep-alive connection's target is decided once, from its first request. This is the inherent cost of operating at the TCP level rather than doing full L7 reverse-proxying. If two requests need different backends and are sharing one keep-alive connection, this isn't the right tool.
Configuration
import { AppModule as ProxyAppModule } from '@thescaffold/ntx-apps-proxy';
ProxyAppModule.forRoot({
routes: [
{
name: 'legacy-api',
match: { pathPrefix: '/apps/legacy' },
target: { host: '127.0.0.1', port: 4001 },
},
{
name: 'partner-webhook',
match: { host: 'partner.example.com' },
target: { host: 'partner-backend.internal', port: 443, tls: true },
priority: 10, // lower runs first; ties broken by insertion order
},
],
persistence: true, // load + merge DB-persisted routes (default true)
tcp: {
headTimeoutMs: 250, // give up waiting for a full header block after this
maxHeadBytes: 8192, // cap on buffered bytes while looking for headers
connectTimeoutMs: 3000, // timeout connecting to a matched target
},
});forRootAsync({ imports, inject, useFactory }) is also available for
config sourced from ConfigService/env, same shape as the other
forRootAsync-style modules in this monorepo.
A route needs at least one of match.host / match.pathPrefix /
match.pathRegex — a route with none is rejected (logged, skipped) rather
than silently matching everything. Multiple criteria on one route are
combined with AND (all specified ones must match); routes are checked in
ascending priority order (default 100), first match wins.
Why no path rewriting
Forwarding is a byte-for-byte tunnel: once a route matches, the buffered
head bytes (request line + headers, exactly as the client sent them) are
pushed back onto the socket and piped straight to the target — see "Why
TCP-level" above. A route matching pathPrefix: '/apps/legacy' forwards
GET /apps/legacy/users to the target exactly as /apps/legacy/users, not
/users; the target must itself be mounted under that same prefix.
This is a deliberate consequence of the architecture, not an oversight:
- Performance. Rewriting the path means parsing the request line out of the buffer, splicing in a new path, and re-serializing it — extra work on every single matched connection, on the hot path this app exists to keep cheap. Piping the untouched buffer costs nothing beyond the one head-parse already done to make the routing decision.
- Consistency with the blind-tunnel model. Everything past the head parse point — request body, subsequent pipelined requests on the same keep-alive connection, chunked-encoding framing — is streamed through unexamined. Rewriting just the first line would make routing look like a full L7 reverse proxy while every other byte still passes through untouched, an inconsistent half-measure. A true L7 proxy (rewriting paths, headers, or bodies) is a different tool with different trade-offs than the one asked for here (TCP-level, lowest latency).
- No ambiguity about what the target receives. The backend behind a
route sees literally what the client sent. There's no separate "public
path" vs. "internal path" mapping to keep in sync as routes change — the
route's
pathPrefix/pathRegexis the contract the target must satisfy.
Sample route configs
Each example shows one match dimension in isolation, then a couple combining
more than one. Criteria on a single route are ANDed together — a route
matches only if every criterion it specifies passes; unspecified criteria
are ignored. Routes are evaluated in ascending priority order (default
100), first match wins.
// 1. Path prefix only — any host, any method.
// Matches GET /apps/legacy/users, POST /apps/legacy/users/1, etc.
{
name: 'legacy-api',
match: { pathPrefix: '/apps/legacy' },
target: { host: '127.0.0.1', port: 4001 },
}
// 2. Host header only — route by virtual host, e.g. multi-tenant on one port.
{
name: 'partner-site',
match: { host: 'partner.example.com' },
target: { host: 'partner-backend.internal', port: 8080 },
}
// `host` also accepts an array, for multiple hostnames sharing one target.
{
name: 'marketing-sites',
match: { host: ['acme.com', 'www.acme.com'] },
target: { host: '127.0.0.1', port: 4010 },
}
// 3. Path regex — for patterns a prefix can't express.
{
name: 'versioned-uploads',
match: { pathRegex: '^/(v1|v2)/uploads/.*\\.(png|jpg)$' },
target: { host: '127.0.0.1', port: 4020 },
}
// 4. Method restriction, usually combined with another criterion.
// GET /apps/bridge/webhook would NOT match — it falls through to the local app.
{
name: 'webhook-ingest',
match: { pathPrefix: '/apps/bridge/webhook', methods: ['POST'] },
target: { host: '127.0.0.1', port: 4030 },
}
// 5. Host + path combined — narrowest match. Carves out one path on one
// hostname to an external server while everything else on that host stays
// local. Lower priority runs first, so this is checked before any broader
// partner.example.com rule.
{
name: 'partner-status-page',
match: { host: 'partner.example.com', pathPrefix: '/status' },
target: { host: 'status.upstream.io', port: 443, tls: true },
priority: 5,
}
// 6. External (non-localhost) target — same shape as any other route; set
// `tls: true` if the backend terminates TLS itself.
{
name: 'external-billing',
match: { pathPrefix: '/apps/billing' },
target: { host: 'billing.vendor.com', port: 443, tls: true },
}DB-persisted overrides
Routes can also be managed at runtime via the route CRUD API (mounted at
apps/proxy/route, same CrudControllerFactory shape as every other
resource in this monorepo). A DB row overrides a code-defined route sharing
the same name; a DB row with a new name is simply added. The merged,
sorted routing table is kept in memory (not re-queried per connection) and
refreshed on every route write and once at boot.
GET apps/proxy/table returns the currently active merged routing table —
useful for confirming a DB override actually took effect.
main.ts integration
Because routing must happen before Fastify ever sees a connection, the
consuming app's Fastify/Nest http server must never itself bind to the
public port — it only needs to exist (so its request-handling listener is
wired up) and receive sockets via the in-process handoff described above.
This is the one non-additive change required; everything else (importing
ProxyAppModule.forRoot(...), adding entities/migrations, a
RouterModule path entry, the package dependency) is purely additive, same
as every other app in this monorepo.
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ ... }),
{ ... },
);
// ... existing plugin registration (helmet, compression, cookies, etc.) ...
const proxyAppService = app.get(ProxyAppService); // AppService, aliased on import from '@thescaffold/ntx-apps-proxy'
const proxyServer = proxyAppService.bind(app.getHttpServer());
const configService = app.get(ConfigService);
const port = +configService.get('PORT', 7000);
await app.init(); // instead of app.listen(port, '0.0.0.0')
proxyServer.listen(port, '0.0.0.0'); // the only socket bound to the public portIf bind() is never called, the app behaves exactly as it does today — the
proxy frontend is entirely opt-in.
HTTP-mode routes (opt-in, no main.ts change)
Set mode: RouteModeType.Http on a route to have it handled entirely within
Nest instead of at the TCP frontend:
ProxyAppModule.forRoot({
httpProxy: true, // registers the catch-all route below — default false
routes: [
{
name: 'audited-partner-api',
mode: RouteModeType.Http,
match: { pathPrefix: '/apps/partner' },
target: { host: 'partner-backend.internal', port: 8080 },
},
],
});httpProxy: true registers one extra catch-all Nest route (@All('*'))
that only takes effect once nothing more specific in the whole app already
claims a given path — Fastify's router always prefers static/parametric
routes over a bare wildcard regardless of registration order, so this can
never shadow another app's real controller routes. For forRootAsync, pass
httpProxy as a sibling of useFactory (not inside its returned object) —
Nest needs to know synchronously, at module-build time, whether to wire the
controller in at all; it can't wait on an async-resolved value.
Why you'd want this over the default tcp mode: the request goes through
this app's real authentication, rate-limiting, logging, and any other
global guards/interceptors before being forwarded — useful when a target
needs to be gated by policy this app already enforces, not just reachable.
The trade-off is everything tcp mode is built to avoid: a full HTTP parse,
guard/interceptor overhead, and (see below) an unavoidable side effect on
paths that don't match anything at all.
No main.ts change, ever — this is a normal Nest module/controller;
httpProxy: true and ProxyAppModule.forRoot(...) are the only changes
needed, same as adding any other app.
Caveats, read before enabling:
- Global middleware/guards/interceptors now run for every previously-404
path too. Before this controller exists, a typo'd or otherwise-unmatched
URL never reaches Nest's pipeline at all — Fastify's router 404s it
directly. Once the catch-all is registered, any unmatched request in the
whole app is dispatched to it, so global middleware (e.g. an
app-wide auth middleware applied via
forRoutes('(.*)')) and anyAPP_GUARD/APP_INTERCEPTORnow execute for those requests as well — not just for configured proxy routes. The response body for a genuine non-match still comes back as a plain 404 (NotFoundException, not proxied), but the guard/interceptor side effects (auth checks, throttle counters, logging) do run. This is an inherent consequence of Fastify needing some registered route to dispatch to, not something a handler can opt back out of after the fact. - No path rewriting, same rule as
tcpmode — the target receives the exact original request-target (path + query string) unmodified. - Body fidelity depends on
rawBody: true. This mode runs after Fastify's own body parser, so by default it forwards the already-parsedreq.bodyre-serialized — which can't losslessly reproduce arbitrary content types (multipart uploads in particular). PassrawBody: truetoFastifyAdapterin main.ts (already the case in this monorepo's existing apps) so the untouched original bytes are forwarded instead. - Redirects and streaming responses. The target's response is buffered
in full before being relayed (
responseType: 'arraybuffer'); this mode isn't meant for very large or long-lived streaming responses — usetcpmode for those.
