npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 via httpProxy: 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
  • 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/pathRegex is 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 port

If 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 any APP_GUARD/APP_INTERCEPTOR now 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 tcp mode — 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-parsed req.body re-serialized — which can't losslessly reproduce arbitrary content types (multipart uploads in particular). Pass rawBody: true to FastifyAdapter in 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 — use tcp mode for those.