=== DRY Konsolidasi === - handleRelay, requireAuth, handleHealth, handleIndex, getClientIP → 1x di src/lib/router.ts - api/relay.ts, src/worker.ts, src/index.ts jadi thin wrapper === Dead exports === - 35 dead exports dibersihkan (dari router.ts, worker.ts, relay.ts) - Duplikasi simbol dari 12 ke 3 (wajar) === Keamanan === - Default API key sk-dummy-key dihapus — requireAuth return null saat key kosong - Semua CORS konsisten via getCorsHeaders() === Kompleksitas === - handleRequest → pecah ke validateRelayTarget, createJsonErrorResponse, dll - transformAnthropicStream (170 baris) → 4 sub-fungsi (emitInitEvents, emitDoneEvents, etc) - handleAnthropicMessages → handleUpstreamError, buildStreamHeaders, buildJsonHeaders - Fix duplicate stream? field di AnthropicRequest interface === Test Coverage === - Test naik dari 171 ke 248 (+77 test) - File baru: src/router.test.ts (requireAuth, getClientIP, CORS, health, index) - File baru: src/relay-integration.test.ts (filterResponseHeaders, shouldSendBody, buildRelayRequest, classifyFetchError, createRelayResponse, normalizeTargetUrl, SSRF, isPrivateIp) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Edge Proxy Relay -- Pure Bun HTTP + WebSocket Relay
A high-performance HTTP and WebSocket relay proxy built entirely with Bun's standard library. Zero framework dependencies. No Next.js, no Express, no React.
Accepts requests with an x-relay-target header and forwards them to the upstream target. Supports both HTTP relay and WebSocket relay in a single Bun.serve() instance.
Quick Start
bun install
bun run dev # development with HMR
bun start # production
The server starts on http://localhost:3000 by default.
| Provider | Endpoint |
|---|---|
| Primary (Vercel) | https://proxy-bun.vercel.app |
| Secondary (CF Workers) | https://cfproxy.asepharyana.my.id/ |
| Interactive Docs | https://proxy-bun.vercel.app/docs |
Environment Variables
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Server listen port |
RELAY_TIMEOUT_MS |
30000 |
Upstream fetch timeout in milliseconds |
BODY_MAX_BYTES |
1048576 |
Maximum accepted request body size in bytes (1 MB) |
RATE_LIMIT_MAX |
100 |
Maximum requests per sliding window per client IP |
RATE_LIMIT_WINDOW_MS |
60000 |
Sliding window duration in milliseconds (1 minute) |
Endpoints
| Path | Method | Description |
|---|---|---|
/ |
GET | Status page (HTML) |
/health |
GET | Health check (JSON): { "status": "ok", "uptime": ..., "version": "1.0.0" } |
/docs |
GET | Interactive documentation page (HTML) |
/* |
Any | HTTP relay (requires x-relay-target) |
/* |
GET | WebSocket relay (requires x-relay-target with ws:// or wss://, Upgrade: websocket) |
CORS Preflight
Any OPTIONS request to any path returns a 204 No Content response with permissive CORS headers (Access-Control-Allow-Origin: *).
HTTP Relay
Include the x-relay-target header to specify the upstream URL. The request method, body, headers, and query parameters are forwarded transparently.
Required Headers
| Header | Required | Description |
|---|---|---|
x-relay-target |
Yes | Base URL of the upstream target (e.g. https://api.openai.com) |
x-relay-path |
No | Path to append to the target URL (default: /) |
Examples
Simple GET relay:
curl -H "x-relay-target: https://jsonplaceholder.typicode.com/posts/1" \
http://localhost:3000/
POST with body and authorization:
curl -X POST \
-H "x-relay-target: https://api.openai.com" \
-H "x-relay-path: /v1/chat/completions" \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}' \
http://localhost:3000/
Binary upload (streaming):
curl -X PUT \
-H "x-relay-target: https://storage.example.com" \
-H "x-relay-path: /upload/image.png" \
--data-binary "@/path/to/image.png" \
http://localhost:3000/
Supported Methods
GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Response streaming (Server-Sent Events, large payloads) is supported natively.
WebSocket Relay
Set x-relay-target to a ws:// or wss:// URL and the server upgrades the connection and relays bidirectionally.
Node.js Client
import { WebSocket } from "ws";
const ws = new WebSocket("wss://your-proxy.example/relay", {
headers: { "x-relay-target": "wss://echo-websocket.example" },
});
ws.on("open", () => ws.send("Hello via relay!"));
ws.on("message", (data) => console.log("Received:", data.toString()));
ws.on("error", (err) => console.error("WebSocket error:", err));
Browser Client
const ws = new WebSocket("wss://your-proxy.example/relay", {
headers: { "x-relay-target": "wss://echo-websocket.example" },
});
ws.onopen = () => ws.send("Hello via relay!");
ws.onmessage = (event) => console.log("Received:", event.data);
ws.onerror = (err) => console.error("WebSocket error:", err);
With Bun's Built-in WebSocket
const ws = new WebSocket(
"wss://your-proxy.example/relay",
{ headers: { "x-relay-target": "wss://echo-websocket.example" } },
);
ws.onopen = () => ws.send("Hello via relay!");
ws.onmessage = (e) => console.log("Got:", e.data);
The relay handles text frames, binary frames (Buffer, Uint8Array, ArrayBuffer, Blob), and forwards close events with status codes.
Architecture
src/
├── index.ts # Entry point: Bun.serve() with routing, WS relay,
│ # graceful shutdown, middleware orchestration
├── lib/
│ └── relay-utils.ts # URL normalization, SSRF protection, header
│ # filtering, request/response building, error
│ # classification, CORS preflight
└── middleware/
├── index.ts # Barrel exports
├── rate-limiter.ts # In-memory sliding window rate limiter (per IP)
├── logger.ts # Structured JSON logging with TTY colorization
└── body-limiter.ts # Content-Length validation against configurable max
Request Flow
Client Request
|
v
Bun.serve() -- routes: /health, /docs, / --> static handlers
|
+--> OPTIONS? --> 204 CORS preflight response
|
+--> Upgrade: websocket + ws:// target? --> WebSocket relay (bidirectional)
|
+--> HTTP relay:
1. Body size check (413 if exceeded)
2. Rate limit check (429 if exceeded)
3. Normalize target URL from x-relay-target header
4. SSRF validation (403 if blocked)
5. Filter request headers (strip relay, platform, hop-by-hop)
6. Fetch upstream with timeout (504 on timeout, 502 on error)
7. Filter response headers, attach CORS
8. Return relayed response
WebSocket Relay Flow
Client WebSocket Bun.serve() Upstream WebSocket
| | |
|-- upgrade req ---------------> |
| (x-relay-target: wss://) | |
| |--- open upstream ---->|
| |<-- onopen ------------|
|<-- open (101 Switching) ------ |
| | |
|-- send "hello" ------------->| |
| |--- "hello" ---------->|
| |<-- "echo" ------------|
|<-- onmessage "echo" --------- |
| | |
|-- close -------------------->| |
| |--- close upstream --->|
Error Codes
| Status | Code | Meaning |
|---|---|---|
| 204 | -- | CORS preflight success (OPTIONS request) |
| 400 | INVALID_TARGET |
Missing or malformed x-relay-target header |
| 403 | SSRF_BLOCKED |
Target resolves to a private or internal IP range |
| 413 | BODY_TOO_LARGE |
Request body Content-Length exceeds BODY_MAX_BYTES |
| 429 | RATE_LIMITED |
Client IP has exceeded the rate limit |
| 502 | DNS_FAILURE |
DNS resolution failed for the target hostname |
| 502 | CONNECTION_REFUSED |
Upstream actively refused the connection |
| 502 | NETWORK_ERROR |
Generic network error (connection reset, unreachable, etc.) |
| 504 | TIMEOUT |
Upstream did not respond within RELAY_TIMEOUT_MS |
All error responses return JSON with error, code, and message fields, plus CORS headers.
Security
SSRF Protection
The proxy blocks requests to private and internal network ranges:
- IPv4 loopback (
127.x.x.x), private ranges (10.x.x.x,172.16-31.x.x,192.168.x.x), link-local (169.254.x.x) - IPv6 loopback (
::1), link-local (fe80::), unique local (fc00::/fd00::) - Common internal hostnames (
localhost,*.local,*.internal, cloud metadata endpoints)
Header Filtering
Before forwarding requests upstream, the proxy strips:
- Relay control headers (
x-relay-target,x-relay-path,host) - Hop-by-hop headers (
connection,transfer-encoding, etc.) - Platform metadata headers (Vercel
x-vercel-*, Cloudflarecf-*,x-forwarded-*) - Sensitive headers (
cookie,set-cookie,via)
Rate Limiting
In-memory sliding window rate limiter keyed by client IP (default: 100 requests per minute). The Retry-After header is set on 429 responses. A periodic cleanup routine prunes expired entries from memory.
Body Size Limiting
Requests with a Content-Length exceeding BODY_MAX_BYTES (default 1 MB) are rejected with a 413 response. Requests without Content-Length (streaming) are passed through.
Connection Timeout
All upstream fetches are bounded by RELAY_TIMEOUT_MS (default 30 seconds) using AbortSignal.timeout(). Timeouts are classified as 504 responses.
Graceful Shutdown
The server listens for SIGTERM and SIGINT, stops accepting new connections, and exits cleanly.
Deployment
Deploy as a standalone Bun process. No framework adapter required.
# Production
bun src/index.ts
# With environment overrides
PORT=8080 RELAY_TIMEOUT_MS=10000 bun src/index.ts
Deployment Targets
-
Any VPS / VM: Run as a systemd service or under a process manager (e.g.,
pm2,supervisord) -
Railway / Fly.io / Render / Koyeb: Set the build command to
bun installand start command tobun src/index.ts -
Docker: Use the official
oven/bunimageFROM oven/bun:latest WORKDIR /app COPY package.json bun.lock . RUN bun install COPY . . EXPOSE 3000 CMD ["bun", "src/index.ts"]
Development
# Install dependencies
bun install
# Start dev server with HMR
bun run dev
# Run tests
bun test
# Static analysis
bun run lint
License
MIT