A confession before the pitch
Most of my working life, I have reached for a package before I reached for a
thought. Express for routing, http-proxy-middleware for forwarding,
serve-static for files, ws for sockets, compression for gzip, chalk for
colors, morgan for logs, nodemon for reloads, autocannon for benchmarks,
zod for config validation. Each one is reasonable on its own. Together they
form a supply chain you inherit without ever deciding to.
The Zero Dependency hackathon asks one uncomfortable question: what if the
runtime already had most of it? Not the "standard library" in the boring
sense. The runtime APIs that Bun and Node ship with, the ones we walk past
on the way to npm.
So I decided to build the most dependency-normal kind of backend software I
could think of, with zero of them. A reverse proxy, which is the thing
people say you would be insane to hand-roll. This post is the complete
story: what I built, how every file works, what the standard library made
easy, what it made painful, and the edge cases that cost me real hours.
Here is the whole pitch in one number: sixteen npm packages replaced by
the runtime, and the one that mattered most, path-to-regexp at roughly
200 million weekly downloads, killed by 37 lines of URLPattern. One
compiled binary. Four jobs. "dependencies": {}
Run it yourself (the benchmark, the demo, and the proof are all built
in, no install):
git clone https://github.com/f-ei8ht/zeroproxy
cd zeroproxy
make build # one compiled binary
make demo # boots two upstreams + a live dashboard
bun run bench # the built-in load test
What zeroproxy actually is
Let me be precise, because the scope is the whole point. zeroproxy is one
process that does four jobs, none of which reach for a package:
- Reverse proxy. Forwards requests to one or many upstream servers. Bodies stream through without being buffered whole. Multiple upstreams get weighted round-robin load balancing, background health checks, and failover that can replay small request bodies on a retry.
-
HTTP router. Matches incoming requests with
URLPattern: wildcards (/api/*), named parameters (/users/:id), per-route method restrictions, and a correct405 Method Not Allowedwith anAllowheader when the path matches but the method does not. -
Static file server. Serves a directory with correct MIME types,
Rangerequests for partial content (206),ETagandLast-Modifiedcaching (304), directory listings, SPA fallback, and dotfile blocking. -
WebSocket tunnel. Detects an upgrade request, hands it to
Bun.servenatively, and pipes frames both ways with the globalWebSocketclient. Nowspackage anywhere.
And then the operational layer a real deployment needs, which is the part
that usually gets skipped in a hackathon:
-
GET /healthzwith uptime plus request and error counters. - Live config reload via
fs.watch: routes swap in place without dropping a single connection. - TLS serving via cert and key paths in the config.
- Graceful shutdown on
SIGINTandSIGTERMthat drains in-flight requests before exiting. - A built-in load test, so the benchmark is as zero-dependency as the server.
The tool most people build with express plus four middleware packages is
here as a single compiled binary with an empty manifest.
Why Bun, and why this bet
Track C wants an HTTP server or router "built on your language's networking
primitives and nothing above them," that handles concurrent connections
without a framework and speaks the protocol correctly enough to interoperate
with real clients. That described exactly what I wanted to prove.
I chose Bun 1.4, which shipped eight days before the hackathon started. It
was a deliberate, slightly risky bet. Bun 1.4 released a batch of built-ins
whose stated purpose is deleting npm dependencies: URLPattern,
CompressionStream with gzip, deflate, brotli and zstd, native WebSocket
upgrades in Bun.serve, Bun.file, plus Node-core util.styleText and
util.parseArgs. If the event is "how much internet middleware can you live
without," Bun 1.4 is the strongest argument that the answer is "most of it."
The cheat-sheet ruling mattered here: Bun and Deno built-ins count as the
standard library, because the rule as written is "Node (or Deno/Bun)
built-ins only, dependencies is {}". I wrote that reasoning into STDLIB.md
so no judge has to work it out.
The shape of the code
The single most important structural decision was this: src/index.ts is
the only file that touches Bun.serve. Everything else is a pure function
or a factory over its inputs. compileRoutes takes routes and returns
matchers. proxyRequest takes a request and a balancer and returns a
response. serveStatic takes a path and returns a Response. That means
every module can be tested in isolation with no port, no socket, no running
server, which is why the test suite is 100 tests and not 10.
zeroproxy/
src/
index.ts Bun.serve wiring, logging, reload, shutdown
config.ts parseArgs + env + config file + validation
router.ts URLPattern compile and match, 405 Allow
static.ts MIME, Range, ETag, listings, SPA fallback
compress.ts Accept-Encoding negotiation, CompressionStream
proxy/
index.ts streaming fetch, failover, 502/504/413
balancer.ts weighted round-robin
health.ts periodic upstream probes
ws.ts upgrade detection, URL mapping
tests/ 100 tests across 9 files
bench/bench.ts the built-in load test
demo/ two mock upstreams + a live dashboard
public/ files the benchmark serves
flowchart LR
CLIENT([Client])
CORE["one process<br/>one event loop<br/>Bun.serve"]
UP_A[Upstream A]
UP_B[Upstream B]
STATIC[Static Files<br/>on Disk]
WS[WebSocket<br/>Upstream]
CLIENT --> CORE
CORE --> UP_A
CORE --> UP_B
CORE --> STATIC
CORE --> WS
DEPS["dependencies: {}"]
CORE --- DEPS
A single request, end to end
Before the file-by-file tour, here is the whole path one request takes,
because it makes each module obvious:
- The request lands in the
fetchhandler insrc/index.ts. -
/healthzshort-circuits immediately: uptime, requests, errors. No routing involved. - Otherwise the router runs the path against every compiled
URLPatternin order and returns the first match. - If the path matches a route's pattern but not its method, the answer is
405with anAllowheader listing the methods that do match. No match at all is404. - If the matched route has an upstream,
proxyRequeststreams it throughfetch, with failover across the balancer's healthy upstreams. - If the matched route serves static files,
serveStaticstreams aBun.filewith MIME, Range and caching headers. - A WebSocket upgrade on a route with
"ws": trueskips all of that and goes straight tosrv.upgradeplus the frame tunnel. - Whatever the response, compression negotiation runs last: if the client
accepts an encoding and the body benefits, it is piped through a native
CompressionStreamandVary: Accept-Encodingis set.
flowchart TD
REQ([Request]) --> HEALTH{Healthz?}
HEALTH -->|Yes| HEALTH_R([Health Response])
HEALTH -->|No| ROUTER{Router Match}
ROUTER -->|HTTP Proxy| PROXY[proxyRequest]
ROUTER -->|Static Asset| STATIC[serveStatic]
PROXY --> COMP[Compression]
STATIC --> COMP
COMP --> RES([Response])
ROUTER -.->|WebSocket Upgrade| UPGRADE[srv.upgrade]
UPGRADE --> TUNNEL[(WebSocket Tunnel)]
classDef terminal fill:#111827,color:#fff,stroke:#111827;
classDef module fill:#ffffff,color:#111827,stroke:#9ca3af;
classDef decision fill:#f3f4f6,color:#111827,stroke:#6b7280;
classDef ws fill:#ffffff,color:#111827,stroke:#4b5563,stroke-width:2px;
class REQ,HEALTH_R,RES terminal;
class PROXY,STATIC,COMP,UPGRADE,TUNNEL module;
class HEALTH,ROUTER decision;
class UPGRADE,TUNNEL ws;
The files, in detail
src/config.ts - everything a user can touch
config.ts turns three inputs into one validated Config object: CLI flags
via util.parseArgs, the ZEROPROXY_PORT and ZEROPROXY_HOST environment
variables, and an optional JSON config file. The ordering is deliberate:
file first, then flags and env override it. --upstream is repeatable and
adds a catch-all proxy route on top of whatever the file defined, so a
one-liner like bun run src/index.ts --upstream http://localhost:3000 is a
complete working proxy with zero configuration.
The details that matter:
-
Every default is a named constant.
DEFAULT_PORT = 8080,DEFAULT_HOST = "0.0.0.0",DEFAULT_RETRY_BODY_LIMIT = 64 * 1024,DEFAULT_UPSTREAM_TIMEOUT_MS = 30000,DEFAULT_SHUTDOWN_TIMEOUT_MS = 10000,DEFAULT_MIN_COMPRESS_BYTES = 256, andDEFAULT_MAX_REQUEST_BODY_BYTES = 0, where zero means "unlimited". No magic numbers scattered through the logic, and every one of them is surfaced as a config key so a user can change it. - Validation throws named errors instead of silently defaulting. A port outside 1 to 65535 gets "port must be an integer between 1 and 65535". A route with a bad upstream gets "route 2 has an invalid upstream". You never wonder which typo produced which behavior.
-
Routes must set upstream or static, never both, never neither. A route
is a
pattern, an optionalmethod, and one target. If you write a route that does neither, it is rejected at load time. -
Upstreams accept three shapes: a bare URL string, an array of strings,
or objects with
{ url, weight }. A string becomes{ url, weight: 1 }at parse time, so the rest of the code never worries about the difference. -
--rootresolves relative static paths. The config may say"static": "./public"; the process resolves it against the given root so the same config file works no matter where the process is started from.
The Zod substitution lives here. Validating a config with a handful of
typeof and regex checks is a few small functions, not a dependency. A full
schema-validation package for a four-field config is the definition of
over-engineering.
src/router.ts - the package killer in 37 lines
This is the file that kills path-to-regexp, which does roughly 200
million weekly downloads, and is the project's Package Killer entry.
path-to-regexp exists to turn route strings like /users/:id and
/api/* into matchers. Bun ships URLPattern, which does the same job
with the same syntax. The entire router is:
export function compileRoutes(routes: Route[]): CompiledRoute[] {
return routes.map((route) => ({
route,
pattern: new URLPattern({ pathname: route.pattern }),
}));
}
export function matchRoute(compiled, method, pathname) {
for (const { route, pattern } of compiled) {
if (route.method && route.method.toUpperCase() !== method.toUpperCase()) continue;
const result = pattern.exec({ pathname });
if (result) return { route, params: result.pathname.groups };
}
return undefined;
}
Three details worth explaining:
- Routes are compiled once at startup and matched in config order. First match wins, which is the same semantics people expect from a config-driven proxy.
- Method comparison is case-insensitive, and the wildcard capture lands in
params["0"]. The static server reads that capture to decide which file to serve. - The 405 behavior falls out of a separate helper,
allowedMethods. When a path matches no route for the current method but matches a pattern for some other method, that helper collects the restricted methods into aSetand the server answers405withAllow: POST. It is a loop and a Set, not a framework.
src/static.ts - serving files the way HTTP expects
The static module replaces serve-static and send, which power
express.static and pull in a chain of their own. The core is Bun.file,
which gives you size, mtime and MIME type from the filesystem and streams
from disk without ever loading the file into memory.
The hand-written correctness lives in the headers:
-
MIME types. A 28-entry table covers everything a web server
realistically serves, from
text/htmltoapplication/wasm. Anything unknown falls back toapplication/octet-stream. This replaces themime-typespackage, which is a giant lookup of about two thousand types. -
ETag.
createHash("sha1")oversize + lastModified, truncated to 24 hex characters and quoted. Two requests for the same file always get the same ETag, and touching the file changes it. A client'sIf-None-Matchthat contains that ETag gets304 Not Modifiedwith an empty body. - If-Modified-Since. Also handled, with a subtle detail: HTTP dates have second granularity, so the file's mtime is truncated to whole seconds before comparison. Otherwise the ETag path and the date path disagree and you never get a 304.
-
Range requests. The regex
bytes=(\d*)-(\d*)handles the two forms people actually use:bytes=0-99and suffix rangesbytes=-5(last five bytes). A satisfiable single range answers206with aContent-Rangeheader and a body that is literallyfile.slice(start, end + 1), so only those bytes stream from disk. A range past the end of the file answers416withContent-Range: bytes */size. A multi-range header, a malformed header, or a reversed range likebytes=5-2is ignored and the full body is sent, which is exactly what RFC 9110 allows a server to do. -
Security. Two guards. Path traversal is blocked by resolving the
candidate and checking it starts with the base directory plus a separator,
so
../../etc/passwddies. Dotfiles are blocked by rejecting any path segment that begins with a dot, so.envand.gitare never served. -
Directory behavior. If the target is a directory, it looks for an
index file (default
index.html), then a configured SPA fallback, then a generated directory listing with a../parent link, directories sorted before files. The listing HTML is escaped by hand with a five-lineescapeHtml. -
Async all the way.
statandreaddircome fromnode:fs/promises, so the event loop never blocks on disk. That is a measured, deliberate trade: it costs a little throughput, but it keeps the proxy responsive under load, and the README says so.
src/compress.ts - HTTP compression done correctly
compression (about 41 million weekly downloads) negotiates
Accept-Encoding and compresses responses. The negotiation is the part
everyone gets subtly wrong, so this module earns its keep:
-
The parser respects q-values.
Accept-Encoding: gzip;q=0, deflatemeans the client explicitly refuses gzip. Thatq=0is an exclusion, not a preference for nothing, and the code treats it that way. -
Server preference order is zstd, brotli, gzip, deflate. When the
client advertises several, the highest-supported quality wins, and equal
qualities break ties in that order. The wildcard
*covers unlisted encodings. -
The compressor is one line:
body.pipeThrough(new CompressionStream( FORMATS[encoding])), whereFORMATSmaps gzip, deflate, brotli and zstd. Native, streaming, with backpressure, and it round-trips throughnode:zlibin the tests. -
The decision about when to compress matters as much as how.
shouldCompressskips identity, anything that is not a 200, anything already carryingContent-Encoding, anything that is an image, video, audio or font (all already compressed formats), and any body underminCompressBytes(default 256 bytes, because compressing a tiny body costs more than it saves).
The caller sets Vary: Accept-Encoding on every response when compression
is enabled, so caches keep the compressed and uncompressed variants
separate. Skip that header and your cache serves gzip to clients that never
sent Accept-Encoding: gzip.
src/ws.ts - the smallest module
Twenty-five lines, three functions:
-
isWebSocketRequestchecks whether theUpgradeheader equalswebsocket, case-insensitively. That is the entire upgrade detection. -
upstreamSocketUrlbuilds the upstream socket URL. It mapshttptowsandhttpstowss, and takes the path and query from the client request, not from the upstream URL. This mirrors the HTTP proxy, which also ignores any path prefix on an upstream URL, and it means/ws/echotunnels to the upstream's/ws/echo, not to the upstream root. -
toSendableconverts aBufferto a freshArrayBuffercopy. That matters for the buffering logic inindex.ts: a frame received from the client cannot be reused after it is sent, so it is copied before it goes into the pending buffer.
src/proxy/balancer.ts - weighted round-robin
The balancer answers two questions: which upstream gets the next request,
and what is the ordered list of failover candidates.
-
pick()is a weighted round-robin. Each healthy upstream's pick chance is proportional to its weight, and every call consumes one turn so consecutive requests rotate. The tests prove it: with weights 1 and 3, exactly 100 of 400 picks go to the lighter upstream. -
rotate()returns the failover candidate order: every healthy upstream exactly once, higher weight first, rotated so the next pick lands at the front, and deduplicated. A single request therefore never tries the same upstream twice. -
mark(url, healthy)is how the health checker talks to the balancer. A target marked unhealthy drops out of bothpickandrotateuntil it is marked healthy again. - If every upstream is dead,
pick()falls back to the first configured target anyway. That sounds odd, but it is deliberate: the request still gets a real attempt and a proper502, instead of a crash or an empty candidate list.
src/proxy/health.ts - keep the dead out of rotation
A proxy that retries into a target it already knows is down is wasting a
retry. healthChecker fixes that with a timer.
- At startup it snapshots each balancer's upstream list.
- On every interval (default 5 seconds) it probes every upstream in
parallel with a
GETto the configured path, each probe bounded byAbortSignal.timeout(default 2 seconds). - Healthy is any status below 500. A 404 means the server is up, it just does not like that path. Only a 5xx or a connection failure marks it down.
- The result is handed to
balancer.mark(url, ok), which is the only interface between health and load balancing. -
checkOnceis public so the tests can drive a full probe cycle with no timers, which is why the health tests run in milliseconds instead of waiting on a real interval. -
startunrefs the timer, so a running health check never keeps the process alive by itself, andstopclears it during reload and shutdown.
src/proxy/index.ts - the heart of the proxy
This is where the four jobs stop being separable. proxyRequest does the
RFC 9110 hygiene, the body planning, and the failover.
Header hygiene. Hop-by-hop headers are stripped per RFC 9110, plus
anything the Connection header names (so a custom Connection: foo
strips foo too). Host is deleted so the upstream sees its own host. The
request gains x-forwarded-host, x-forwarded-proto, and a Via header
appended as 1.1 zeroproxy. The response gets the same treatment plus
access-control-allow-origin: *, which quietly kills the cors package.
redirect: "manual" means a 3xx from the upstream is forwarded to the
client as-is, not followed by the proxy.
Timeouts. Every attempt runs with AbortSignal.timeout. A
TimeoutError surfaces as 504 Gateway Timeout; any other connection
failure surfaces as 502 Bad Gateway.
The body plan. This is the subtle part, and it deserves its own section
below. planBody decides, before the body is ever sent, whether a retry is
even possible. No body: replayable. A declared Content-Length at or below
retryBodyLimitBytes (64 KB default): buffered and replayable. A declared
length above the limit: sent exactly once, never retried, because a consumed
stream cannot be sent twice. An unknown-length chunked body: read up to the
limit; if it ends within the limit it is replayable, and if it keeps coming,
spliceTail stitches the already-read bytes in front of the unread tail and
streams the whole thing once, so memory stays bounded no matter how large
the upload is.
The retry rule. Connection failures are retried for every method,
because a connection refusal proves nothing was delivered. Timeouts are
retried only for idempotent methods: GET, HEAD, PUT, DELETE, OPTIONS, TRACE.
The reasoning is one line and it is the most important line in the project:
a timeout does not prove the request was never delivered. Replaying a POST
that a slow upstream already applied would apply it twice. So a timed-out
POST/PATCH is never replayed, and surfaces as a 504.
The failover candidate list is [primary, ...rotate() minus primary], so a
request visits each healthy upstream at most once, starting from the
balancer's pick.
src/index.ts - the only file that touches Bun.serve
Everything above is pure. This file is where it all gets wired to reality.
-
State. A
Runtimeobject holds the config, the compiled routes, a map of balancers keyed by route pattern, and the health checker.applyConfigrebuilds all of it: it stops the old health checker, recompiles routes, rebuilds balancers and starts a fresh checker. The same function runs at boot and on every config reload, which is why live reload is not a special case. -
The fetch handler. Match the route, check for a WebSocket upgrade, and
either upgrade or call
handle. Every response is logged as one line with a colored status viautil.styleText: green under 400, yellow under 500, red from 500 up, each with an ISO timestamp and duration. -
The healthz endpoint.
status: "ok", uptime in seconds from a moduleSTARTtimestamp, and the cumulativerequestsanderrorscounters. That is the whole metrics story, and the demo dashboard polls it. -
The compression wiring. When compression applies, the response is
rebuilt with a compressed body:
content-lengthdeleted (the length is unknown until the stream ends),content-encodingset,varyappended. When compression does not apply, the code still appendsvarybut does NOT rebuild the response. That choice is a bug fix, and it is one of the afternoons below. -
The WebSocket handlers.
openconnects the upstream socket and flushes any client frames that arrived before the upstream was ready.messageforwards each frame, buffering until the upstream isOPEN.closetears down the upstream. Three handlers, and a socket is tunneled with nowspackage. -
Live reload.
fs.watchon the config file re-runsloadConfigandapplyConfigon change. A bad config logs red and keeps the previous routes, so you cannot reload yourself into a dead server. -
Graceful shutdown. On
SIGINTorSIGTERM: stop health checks, close the watcher, stop accepting new connections, and exit aftershutdownTimeoutMswith an unref'd timer. In-flight requests drain before the process exits. -
The
import.meta.maingate. The server only boots when the file is run directly, so tests and the demo can import it without side effects.
The supporting cast - tests, bench and demo
tests/ is 100 tests across 9 files, run by bun:test, which is built in.
No Jest, no Vitest, no exception needed. By the numbers: 100 of 100 passing, zero skipped, zero edited, all runnable with one bun test and no install. The unit suites call the pure modules directly: router matching and 405 Allow, weighted rotation and
failover (including the timeout rule, body replay and the 413 caps), every
static header case from single ranges to reversed ranges, compression
negotiation with q-values, config validation, health probes, and the
WebSocket URL mapping. Two suites go further. server.test.ts boots the
real server as a subprocess and exercises /healthz, proxying, 405, static
serving, a live WebSocket tunnel, live config reload, and a clean SIGTERM
exit. demo.test.ts boots the whole demo package and proves the dashboard
serves, Range works on the demo file, proxy requests alternate between the
two mock upstreams, and the tunnel echoes.
bench/bench.ts is the autocannon replacement. It spawns the server, fires
20,000 requests across 32 concurrent workers using only fetch and
performance.now, and reports throughput plus p50, p90, p99 and max from a
sorted latency histogram. No package to install, and it works in CI.
demo/ is the click-and-play proof that this is not scaffolding. Two mock
upstreams (each a tiny Bun.serve answering /api/whoami with its own
name, so round-robin is visible), a config putting zeroproxy in front of
them, and a dashboard page that is plain HTML, CSS and JavaScript with no
framework and no build step, served by zeroproxy's own static file server.
The dashboard polls /healthz, sends requests through the proxy, asks for
the first 100 bytes of a file with a Range header, and opens a WebSocket
through the tunnel. demo/run.ts starts everything with one command and
waits for the proxy to answer before printing the URL.
sequenceDiagram
participant C as Client
participant Z as zeroproxy
participant A as Upstream A
participant B as Upstream B
C->>Z: Request
Z->>A: Forward request
A--xZ: Connection refused
Note over Z: Retry on connection refusal
Z->>B: Retry request
B-->>Z: 200 OK + streaming body
Z-->>C: Streaming response
Note over C,B: Connection refusal is retried for every method.<br/>Timeouts are retried only for idempotent methods.
The edge cases that ate my afternoons
The write-up prompt asks for the edge case that ate an afternoon. I have
three, and each one changed the code permanently.
1. A consumed stream cannot be sent twice
The first failover version buffered every request body so it could be
replayed. Then I realized a large upload would buffer entirely into memory,
and that is how a proxy becomes a memory bomb. The fix is the three-way
planBody split above, with spliceTail for chunked bodies. But the real
afternoon was spent on the retry rule, not the buffering. I shipped a
version that retried POSTs after a timeout, and it took a test that should
have been obvious to catch it: a slow upstream that actually applied the
POST, then timed out the response. The retry applied it again. The rule that
fixed it, "a timeout does not prove the request was never delivered," is now
the most documented line in the source, and there is a test pinning it.
2. Re-wrapping a 206 response sends the whole file
I implemented Range, the static tests passed, everything was beautiful. Then
I turned compression on and a bytes=0-99 request returned the entire file.
The cause is documented in src/index.ts because it is so easy to hit
again: re-wrapping a file-backed body, such as a 206 slice, in a new
Response object makes Bun.serve re-stat the file and send all of it. The
fix is in the compression wiring: when compression does not apply, append
Vary: Accept-Encoding to the existing response instead of rebuilding it.
A real proxy bug, found by a real integration test, fixed with one else.
3. The reproducible build is off by one byte
The +5 Reproducible Build bonus asks for two builds with byte-identical
output. My first two builds did not match. The diff was exactly one byte.
Bun embeds the --outfile filename into the compiled binary, so two builds
named zeroproxy-build-1 and zeroproxy-build-2 are different programs by
construction. The fix is to stop fighting it: build twice to the same
filename, copy the results apart, and hash the copies. The reproduce
target does exactly that, and BUILD_HASHES.txt now shows the same SHA-256
twice.
What the standard library made painful
The prompt asks this directly, so here is the honest list of places where I
hit the edge of the box and had to design around it, rather than install my
way out.
-
No client IP address, so no real
X-Forwarded-For. Bun'sfetchhandler does not expose the client socket address. A proxy that cannot see its clients cannot build the client address chain, so the header is left to whatever front proxy already set it. I documented this instead of faking it, because faking a security-relevant header is worse than omitting it. -
HTTP/1.1 only.
Bun.servespeaks HTTP/1.1. No HTTP/2, no HTTP/3, no ALPN. Hand-writing hpack in a 72-hour window is a project in itself, so the limit is stated honestly and the scaling story is horizontal: N processes behind an OS-level load balancer. -
util.parseArgsis string and boolean only. No coercion, no subcommands. It is enough for a proxy because the config file carries the real types, but it is a genuine ceiling, and Node's own docs say the API is deliberately minimal. -
No MIME database in the box. Node and Bun do not ship the full IANA
extension-to-type map, so the static module carries a 28-entry table. It
covers every extension a web server realistically serves, and it is a
real maintenance surface that
mime-typeswould have given away. - A live WebSocket cannot be failed over. The initial target is load-balanced, but if an upstream dies mid-connection there is no transparent way to replay an established handshake. The socket dies with its upstream. That is in the README, not hidden.
The substitutions at a glance
STDLIB.md logs sixteen substitutions, each with a rationale and a download
count where it matters. The headline ones:
| Would normally install | Used instead |
|---|---|
path-to-regexp (~200M/wk) |
URLPattern |
http-proxy-middleware |
fetch() + Bun.serve() streaming |
ws (~270M/wk) |
Bun.serve upgrade + global WebSocket
|
serve-static / send
|
Bun.file() + manual Range/ETag
|
compression (gzip middleware) |
CompressionStream |
mime-types (~263M/wk) |
28-entry hand-written table |
chalk (~505M/wk) |
util.styleText() |
minimist (~158M/wk) |
util.parseArgs() |
morgan |
a ~10-line logger |
http-errors (~170M/wk) |
hand-written Response builders |
zod |
hand-written config guards |
cors (~77M/wk) |
one header on proxied responses |
dotenv |
process.env + JSON config |
nodemon (~14M/wk) |
fs.watch + in-place reload |
autocannon |
stdlib fetch bench script |
| a status dashboard | one static HTML page polling /healthz
|
The numbers, honestly
The benchmark is built in and reproducible: bun run bench serves static
files and fires 20,000 requests across 32 concurrent workers. On my ThinkPad
E16 Gen 2, AMD Ryzen 5 7535U, Manjaro Linux, Bun 1.4:
| Metric | Value |
|---|---|
| Throughput | ~4,900 req/s |
| p50 latency | ~6 ms |
| p90 latency | ~8 ms |
| p99 latency | ~13 ms |
Single process, single event loop. I am not going to pretend these beat
nginx or Caddy, because they do not, and the event's rule is that honest
numbers beat fast ones. The honest claim is different: this is what the
standard library does when you stop adding middleware and let one event loop
do its job.
The proof of zero dependencies is equally boring and equally the point.
package.json has "dependencies": {}. deps-proof.txt holds the
bun pm ls output, which lists only the TypeScript toolchain in
devDependencies, and that toolchain never ships in the compiled binary.
BUILD_HASHES.txt holds two identical SHA-256 hashes. One command, make, produces the binary.
buildmake demo boots the whole package.
The proof you can verify, not take my word for
The one accusation that could sink a zero-dependency submission is "you hid
a dependency." So I made it impossible to make. Three artifacts in the repo,
each independently checkable:
-
package.jsonis"dependencies": {}. Not a lockfile full of transitive tree, an empty manifest. -
deps-proof.txtis the rawbun pm lsoutput. Run it yourself on the same machine and you get the same list: only the TypeScript toolchain in devDependencies, which never ships in the compiled binary. -
BUILD_HASHES.txtholds two byte-identical SHA-256 hashes. This is the +5 Reproducible Build bonus, and it was not free: my first two builds were off by exactly one byte, because Bun embeds the--outfilefilename into the binary. The fix is in the edge cases below. The point is that the proof is structural. Two independent builds, same hash, so nobody has to trust a claim that a dependency stayed out.
Every one of these is a file in the repo. A judge can check all three in
under a minute.
The decisions I'd take back
Given another 72 hours, the honest next mountain is HTTP/2, and it is the
right one because it is the one place the runtime genuinely stops. I would
also implement multi-range multipart/byteranges, which today is correctly
ignored per RFC 9110 rather than implemented. Neither is hidden; both are
stated limits in the README, which is the whole deal.
But there are two calls I made that I would genuinely reverse, not just
extend.
The X-Forwarded-For omission is the one I'd rethink hardest. Bun's
fetch handler does not expose the client socket address, so I documented
the gap rather than fake the header. I still think faking a security-relevant
header is wrong. But a proxy that cannot see its clients is a proxy missing
its most basic observability signal, and I gave it away earlier than I
should have. The honest fix is to stop treating it as a hard wall and ask
the runtime for the address more aggressively, or to make the header
opt-in and configurable rather than absent. I would spend a real chunk of
the next window there, because it is the difference between a toy proxy and
a deployable one.
I would not spend so long on the demo dashboard. It is a nice proof that
this is not scaffolding, and the reviewers noticed it. But the hours went in
at the very end, when the clock was shortest, and it is the least
dependency-relevant part of the submission. The scoring weight is on the
correctness of the proxy and the honesty of the write-up, not on how pretty
the demo page is. If I ran it again, the dashboard would be a curl script
and a raw HTML page, and the saved time would go into HTTP/2 or multi-range
support instead.
And the honest bottom line stays: every limit above is stated in the README,
not hidden. That is the whole deal.
The takeaway
The event's slogan is "every dependency is a stranger." Building zeroproxy
made that concrete in a way reading about it never could. For every feature I
caught my hand reaching for a package out of habit, and every time the
runtime already had the answer: URLPattern for routing, CompressionStream
for compression, Bun.serve upgrade for sockets, Bun.file for static,
util.styleText for chalk, util.parseArgs for minimist, fs.watch for
nodemon. The one thing the runtime genuinely lacked, the client IP address,
I documented instead of faking.
The package I made look unnecessary is path-to-regexp, and it is not even
close. The runtime replaced it with identical syntax and semantics, so the
router is 37 lines. The thing that surprised me most was that building the
proxy with zero dependencies was not harder than building it with packages.
It was calmer. No version conflicts, no audit noise, no transitive tree to
reason about. Just one event loop and the protocol, which is exactly what
the hackathon promised and exactly what I wanted to prove.
Zero dependencies. One command to run. Every line mine.
GitHub: github.com/f-ei8ht/zeroproxy
Demo video: youtube.com/watch?v=ugBxX1BZD70
by Saif Ali Khan
#hackathonraptors
September 2026.
Top comments (0)