Skip to content

Reverse proxy (s200/proxy) ​

Fetch-based reverse-proxy primitives — the runtime hop that completes s200's gateway story. s200/gateway generates the nginx/K8s/ALB configs, s200/shard-dev dispatches across in-process shards, and this module forwards real traffic: stream a request to an upstream URL over fetch, hand the upstream response back, hop-by-hop headers stripped both ways. See the sharding & scheduling guide for the layer above.

proxyRequest — the low-level forward ​

ts
import { proxyRequest } from 's200/proxy';

get(app, '/legacy', (ctx) => proxyRequest(ctx, 'http://127.0.0.1:8080/legacy'));

Forwards ctx's request to an absolute target URL and returns the upstream response with hop-by-hop headers stripped. Callers own ctx.res: a handler returns the result, a middleware assigns it. Pending setHeader values drain onto the returned response, so headers stamped earlier in the chain (request-id, security headers) ride the proxied response too.

ProxyInitMeaningDefault
headersExtra upstream request headers, applied after the forwarded set — an explicit header (authorization, accept-encoding) wins—
signalAn extra abort source, composed with ctx.signal via AbortSignal.anyctx.signal alone
preserveHostForward the incoming host header verbatimfalse (dropped; runtime pins Host from the target URL)
clientAddressThe address appended to x-forwarded-for: a string, or a per-request resolveromitted → existing chain passes untouched
redirectUpstream redirect mode'manual' (3xx passes through, Location verbatim)

createProxy — prefix dispatch as a middleware ​

ts
import { createProxy } from 's200/proxy';

use(app, '/api', createProxy({
  upstream: 'http://127.0.0.1:8080',
  rewrite: (p) => p.replace(/^\/api/, '') || '/',
}));

Every request that reaches the middleware is forwarded to upstream (origin + optional path prefix + rewritten pathname + the incoming search) and the upstream response becomes ctx.res. The middleware is a terminal — it never calls next() — so the use prefix above is the dispatch: everything under /api is now the upstream's, with rewrite typically stripping the mount prefix.

CreateProxyOptionsMeaningDefault
upstreamBase URL of the upstream — origin plus optional path prefix; its own query/hash are a setup error (the request's query is what ships)required
rewriteRewrites the pathname before forwardingidentity
preserveHostSee ProxyInit.preserveHostfalse
clientAddressSee ProxyInit.clientAddressomitted

Semantics ​

  • Streaming, no buffering. The request body (ctx.req.body) is handed to fetch with duplex: 'half' — the upstream sees bytes as the client produces them; the response body stays a stream end to end. Proxying consumes the request body: a middleware that already read it (readJson & co) breaks the forwarding, and proxyRequest throws a plain Error saying so.
  • Hop-by-hop stripped both ways. RFC 9110 §7.6.1 (connection, keep-alive, proxy-authenticate, proxy-authorization, te, trailer, transfer-encoding, upgrade) plus every token the incoming connection header names — from the request and from the response.
  • x-forwarded-* accumulates. x-forwarded-proto and x-forwarded-host are appended from the incoming request; x-forwarded-for appends the address from clientAddress — a hop with no address to claim leaves the chain untouched rather than appending garbage (first-hop keying in s200/rate-limit and s200/trust-proxy stay honest).
  • accept-encoding pinned to identity. The fetch transport transparently decodes content-codings, so a compressed upstream would pair a decoded body with stale content-encoding/content-length headers. Pinning identity keeps the proxied bytes and headers honest end to end; an explicit accept-encoding in init.headers overrides the pin.
  • Content-length is passed through, never fabricated. Whatever length the upstream set rides along with the stream — the explicit-CL contract applies to bodies s200 creates, not pass-through responses. Bodyless statuses (204/205/304) ship with no body.
  • Host follows the fetch spec. Spec-strict runtimes (undici/node, browsers) pin Host to the target URL's authority regardless of the forwarded header set; preserveHost: true forwards the incoming header verbatim — honored by runtimes that allow a caller-set Host (Bun), overridden on spec-strict ones. The reliable carrier of the original host is the appended x-forwarded-host.
  • No upgrade proxying. Protocol upgrades (WebSockets) are not proxied — fetch has no upgrade path; terminate those with s200/websocket instead. No Location rewriting happens either: a 3xx passes through verbatim under the default redirect: 'manual'.

Failure mapping ​

A fetch that rejects maps to httpError through the standard error boundary (onError and the in-chain response contract apply):

ConditionResponse
The linked signal aborted — client disconnect, or a timeout battery's deadline firing on ctx.signal499 Client Closed Request (nginx's convention: the request's own side gave up, not the upstream)
Any other network failure — connection refused, DNS, TLS502 Bad Gateway

The transport detail (errno, TLS reason) stays internal — the client learns the mapping, not the topology.

Released under the MIT License.