feat: migrate from Next.js to pure Bun relay proxy

- Replace Next.js app router with standalone Bun.serve() entry point
- Add middleware stack: rate limiter, body limiter, structured logger, SSRF protection
- Add WebSocket bidirectional relay via x-relay-target header
- Implement error classification (DNS→502, timeouts→504, SSRF→403, rate→429)
- Remove all Next.js dependencies and config files
- Update deploy workflow, tsconfig, wrangler config for Bun deployment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-10 19:54:55 +07:00
co-authored by Claude Fable 5
parent 124dab98a2
commit f58a92e2aa
43 changed files with 2981 additions and 1659 deletions
+5 -22
View File
@@ -1,32 +1,15 @@
name: Deploy to Cloudflare Workers name: Deploy
on: on:
push: push:
branches: [main, master] branches: [master]
workflow_dispatch:
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: oven-sh/setup-bun@v2 - uses: oven-sh/setup-bun@v2
with: with:
bun-version: latest bun-version: latest
- run: bun install
- name: Install dependencies - run: bun run build
run: bun install - run: bun test
- name: Build worker
run: bun run build:worker
- name: Deploy to Cloudflare
run: bunx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+5
View File
@@ -13,6 +13,7 @@ coverage
# logs # logs
logs logs
_.log _.log
*.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files # dotenv environment variable files
@@ -39,3 +40,7 @@ next-env.d.ts
# OpenNext & Wrangler build output # OpenNext & Wrangler build output
.open-next/ .open-next/
.wrangler/ .wrangler/
.claude/
.codegraph/
+10
View File
@@ -1,3 +1,13 @@
# Edge Proxy Relay
A pure Bun HTTP and WebSocket relay proxy using `Bun.serve()`. No Next.js, no Express, no React, no Vercel Edge Runtime. Single entry point at `src/index.ts` -- deploys as a standalone Bun process.
Key architecture facts:
- Entry point: `src/index.ts` (was `src/app/route.ts` in the previous Next.js version)
- Middleware stack: rate limiter, body limiter, structured logger, SSRF protection
- WebSocket relay: bidirectional relay via `x-relay-target` header with `ws://` or `wss://`
- Error classification: DNS errors -> 502, timeouts -> 504, SSRF blocks -> 403, rate limits -> 429
- The old Next.js `src/app/route.ts` still exists as a legacy file but is no longer the active entry point
Default to using Bun instead of Node.js. Default to using Bun instead of Node.js.
+264 -71
View File
@@ -1,119 +1,312 @@
# 🚀 Edge Relay (Proxy Bun) # Edge Proxy Relay -- Pure Bun HTTP + WebSocket Relay
High-performance HTTP Proxy & Relay handler optimized for Vercel Edge Runtime, Cloudflare Workers, and Bun. [![Bun](https://img.shields.io/badge/Bun-0.8+-000?logo=bun&logoColor=fff)](https://bun.sh)
## 🌐 Live Deployment 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.
| Provider | Endpoint | 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.
|----------|----------|
| **Primary (Vercel)** | `https://proxy-bun.vercel.app` |
| **Secondary (CF Workers)** | `https://opennext-app.superaseph.workers.dev` |
| **Leapcell** | `https://proxy-bun-mytheclipse8647-orfq73fe.apn.leapcell.dev` |
| **Interactive Docs** | `https://proxy-bun.vercel.app/docs` |
--- ---
## 🛠 Cara Pakai ## Quick Start
Proxy ini bekerja dengan menangkap request ke endpoint relay dan meneruskannya ke target yang ditentukan via headers. ```bash
bun install
bun run dev # development with HMR
bun start # production
```
The server starts on `http://localhost:3000` by default.
---
## 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 ### Required Headers
| Header | Required | Default | Deskripsi | | Header | Required | Description |
|--------|----------|---------|-----------| |--------|----------|-------------|
| `x-relay-target` | **Yes** | - | Base URL target (e.g. `https://api.openai.com`) | | `x-relay-target` | Yes | Base URL of the upstream target (e.g. `https://api.openai.com`) |
| `x-relay-path` | No | `/` | Path tambahan (e.g. `/v1/chat/completions`) | | `x-relay-path` | No | Path to append to the target URL (default: `/`) |
--- ### Examples
## 📖 Contoh Penggunaan **Simple GET relay:**
### 1. Simple GET Request
Mengambil data dari JSONPlaceholder.
```bash ```bash
curl -H "x-relay-target: https://jsonplaceholder.typicode.com/posts/1" \ curl -H "x-relay-target: https://jsonplaceholder.typicode.com/posts/1" \
https://proxy-bun.vercel.app/ http://localhost:3000/
``` ```
### 2. POST with Body & Headers **POST with body and authorization:**
Meneruskan API Key dan data JSON ke target.
```bash ```bash
curl -X POST \ curl -X POST \
-H "x-relay-target: https://api.example.com" \ -H "x-relay-target: https://api.openai.com" \
-H "x-relay-path: /v1/data" \ -H "x-relay-path: /v1/chat/completions" \
-H "Authorization: Bearer YOUR_TOKEN" \ -H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"key": "value"}' \ -d '{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}' \
https://proxy-bun.vercel.app/ http://localhost:3000/
``` ```
### 3. Binary Data / Upload **Binary upload (streaming):**
Mendukung upload file via `POST`/`PUT` (streaming).
```bash ```bash
curl -X PUT \ curl -X PUT \
-H "x-relay-target: https://storage.com" \ -H "x-relay-target: https://storage.example.com" \
-H "x-relay-path: /upload/image.png" \ -H "x-relay-path: /upload/image.png" \
--data-binary "@/path/to/image.png" \ --data-binary "@/path/to/image.png" \
https://proxy-bun.vercel.app/ http://localhost:3000/
``` ```
--- ### Supported Methods
## ⚙️ Fitur & Spesifikasi `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`. Response streaming (Server-Sent Events, large payloads) is supported natively.
### ⚡ Protocol Support
- **HTTP/1.1 & HTTP/2** - Full support.
- **Methods** - `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`.
- **Streaming** - Mendukung response streaming (Server-Sent Events / SSE) secara native.
- **CORS** - Otomatis menambahkan header `Access-Control-Allow-*` agar bisa diakses dari browser.
### 🛡️ Security & Header Handling
Relay ini bersifat transparan kecuali untuk header berikut yang di-**strip** sebelum diteruskan ke target:
- `host` (diganti dengan host target)
- `x-relay-target`
- `x-relay-path`
Semua header lain (seperti `Authorization`, `User-Agent`, `Cookie`, dsb) akan diteruskan apa adanya.
### 🧪 Error Codes
| Status | Deskripsi |
|--------|-----------|
| `400` | Missing `x-relay-target` header. |
| `403` | Target domain tidak valid (jika whitelist aktif). |
| `502` | Target gagal dihubungi / Bad Gateway. |
--- ---
## 📂 Struktur Project ## WebSocket Relay
Set `x-relay-target` to a `ws://` or `wss://` URL and the server upgrades the connection and relays bidirectionally.
### Node.js Client
```ts
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
```js
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
```ts
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
```text ```text
src/ src/
├── app/ ├── index.ts # Entry point: Bun.serve() with routing, WS relay,
├── route.ts # Entry point proxy (Edge Handler) # graceful shutdown, middleware orchestration
│ └── docs/ # UI Interactive Docs & Tester ├── lib/
└── lib/ │ └── relay-utils.ts # URL normalization, SSRF protection, header
├── relay-utils.ts # Logic filter header & request builder # filtering, request/response building, error
└── utils.ts # Helper UI │ # 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
``` ```
## 🏗 Development ### Request Flow
Gunakan [Bun](https://bun.sh) untuk performa terbaik. ```
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-*`, Cloudflare `cf-*`, `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.
```bash
# 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 install` and start command to `bun src/index.ts`
- **Docker**: Use the official `oven/bun` image
```dockerfile
FROM oven/bun:latest
WORKDIR /app
COPY package.json bun.lock .
RUN bun install
COPY . .
EXPOSE 3000
CMD ["bun", "src/index.ts"]
```
---
## Development
```bash ```bash
# Install dependencies # Install dependencies
bun install bun install
# Run dev server # Start dev server with HMR
bun dev bun run dev
# Run unit tests # Run tests
bun test bun test
# Static analysis
bun run lint
``` ```
## 🚀 Deployment (GitHub Actions)
Project ini otomatis dideploy ke Cloudflare Workers setiap ada push ke `master`.
Konfigurasi workflow ada di `.github/workflows/deploy.yml`.
--- ---
🤖 **Powered by Bun + Next.js Edge Runtime**
## License
MIT
-34
View File
@@ -1,34 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
-25
View File
@@ -1,25 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
-18
View File
@@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
-251
View File
@@ -1,251 +0,0 @@
import { test, expect, describe } from "bun:test";
import {
normalizeTargetUrl,
stripRelayHeaders,
shouldSendBody,
buildRelayRequest,
createRelayResponse,
} from "./relay-utils";
describe("normalizeTargetUrl", () => {
test("returns null when target is null", () => {
expect(normalizeTargetUrl(null, "/")).toBeNull();
});
test("returns null when target is empty string", () => {
expect(normalizeTargetUrl("", "/")).toBeNull();
});
test("removes trailing slash from target", () => {
expect(normalizeTargetUrl("https://httpbin.org/", "/get")).toBe("https://httpbin.org/get");
});
test("keeps target without trailing slash", () => {
expect(normalizeTargetUrl("https://httpbin.org", "/get")).toBe("https://httpbin.org/get");
});
test("appends relay path", () => {
expect(normalizeTargetUrl("https://example.com", "/api/users")).toBe("https://example.com/api/users");
});
test("handles nested paths", () => {
expect(normalizeTargetUrl("https://api.example.com", "/v1/users/123/profile")).toBe(
"https://api.example.com/v1/users/123/profile"
);
});
});
describe("stripRelayHeaders", () => {
test("removes x-relay-target header", () => {
const headers = new Headers({ "x-relay-target": "https://test.com" });
const stripped = stripRelayHeaders(headers);
expect(stripped.get("x-relay-target")).toBeNull();
});
test("removes x-relay-path header", () => {
const headers = new Headers({ "x-relay-path": "/test" });
const stripped = stripRelayHeaders(headers);
expect(stripped.get("x-relay-path")).toBeNull();
});
test("removes host header", () => {
const headers = new Headers({ host: "localhost:3000" });
const stripped = stripRelayHeaders(headers);
expect(stripped.get("host")).toBeNull();
});
test("preserves other headers", () => {
const headers = new Headers({
"x-relay-target": "https://test.com",
"x-custom-header": "value",
authorization: "Bearer token",
});
const stripped = stripRelayHeaders(headers);
expect(stripped.get("x-custom-header")).toBe("value");
expect(stripped.get("authorization")).toBe("Bearer token");
});
test("handles multiple relay headers", () => {
const headers = new Headers({
"x-relay-target": "https://test.com",
"x-relay-path": "/path",
host: "test.com",
"content-type": "application/json",
});
const stripped = stripRelayHeaders(headers);
expect(stripped.get("content-type")).toBe("application/json");
expect(stripped.get("x-relay-target")).toBeNull();
expect(stripped.get("x-relay-path")).toBeNull();
expect(stripped.get("host")).toBeNull();
});
});
describe("shouldSendBody", () => {
test("returns false for GET", () => {
expect(shouldSendBody("GET")).toBe(false);
});
test("returns false for HEAD", () => {
expect(shouldSendBody("HEAD")).toBe(false);
});
test("returns true for POST", () => {
expect(shouldSendBody("POST")).toBe(true);
});
test("returns true for PUT", () => {
expect(shouldSendBody("PUT")).toBe(true);
});
test("returns true for PATCH", () => {
expect(shouldSendBody("PATCH")).toBe(true);
});
test("returns true for DELETE", () => {
expect(shouldSendBody("DELETE")).toBe(true);
});
test("is case-sensitive", () => {
expect(shouldSendBody("get")).toBe(true);
expect(shouldSendBody("post")).toBe(true);
});
});
describe("buildRelayRequest", () => {
test("sets correct method", () => {
const req = new Request("http://test.com", { method: "POST" });
const headers = new Headers();
const result = buildRelayRequest(req, "https://test.com", headers);
expect(result.method).toBe("POST");
});
test("sets headers", () => {
const req = new Request("http://test.com");
const headers = new Headers({ "x-custom": "value" });
const result = buildRelayRequest(req, "https://test.com", headers);
expect(result.headers).toBe(headers);
});
test("omits body for GET", () => {
const req = new Request("http://test.com", { method: "GET" });
const headers = new Headers();
const result = buildRelayRequest(req, "https://test.com", headers);
expect(result.body).toBeUndefined();
});
test("omits body for HEAD", () => {
const req = new Request("http://test.com", { method: "HEAD" });
const headers = new Headers();
const result = buildRelayRequest(req, "https://test.com", headers);
expect(result.body).toBeUndefined();
});
test("includes body for POST", () => {
const body = JSON.stringify({ test: true });
const req = new Request("http://test.com", { method: "POST", body });
const headers = new Headers();
const result = buildRelayRequest(req, "https://test.com", headers);
expect(result.body).toBeDefined();
expect(result.duplex).toBe("half");
});
test("includes body for PUT", () => {
const body = JSON.stringify({ update: true });
const req = new Request("http://test.com", { method: "PUT", body });
const headers = new Headers();
const result = buildRelayRequest(req, "https://test.com", headers);
expect(result.body).toBeDefined();
});
test("includes body for PATCH", () => {
const body = JSON.stringify({ patch: true });
const req = new Request("http://test.com", { method: "PATCH", body });
const headers = new Headers();
const result = buildRelayRequest(req, "https://test.com", headers);
expect(result.body).toBeDefined();
});
test("includes body for DELETE", () => {
const body = JSON.stringify({ delete: true });
const req = new Request("http://test.com", { method: "DELETE", body });
const headers = new Headers();
const result = buildRelayRequest(req, "https://test.com", headers);
expect(result.body).toBeDefined();
});
});
describe("createRelayResponse", () => {
test("preserves status", async () => {
const mockResponse = new Response("body", { status: 418 });
const result = createRelayResponse(mockResponse);
expect(result.status).toBe(418);
});
test("preserves headers", async () => {
const mockResponse = new Response("body", {
headers: { "x-custom": "value" },
});
const result = createRelayResponse(mockResponse);
expect(result.headers.get("x-custom")).toBe("value");
});
test("preserves body", async () => {
const mockResponse = new Response("test body content");
const result = createRelayResponse(mockResponse);
expect(await result.text()).toBe("test body content");
});
test("handles different status codes", async () => {
const mockResponse = new Response(null, { status: 404 });
const result = createRelayResponse(mockResponse);
expect(result.status).toBe(404);
});
test("handles 500 status", async () => {
const mockResponse = new Response("error", { status: 500 });
const result = createRelayResponse(mockResponse);
expect(result.status).toBe(500);
});
});
describe("integration: full relay flow", () => {
test("complete flow with mocked fetch", async () => {
const target = "https://httpbin.org";
const relayPath = "/get";
const targetUrl = normalizeTargetUrl(target, relayPath);
expect(targetUrl).toBe("https://httpbin.org/get");
const originalHeaders = new Headers({
"x-relay-target": target,
"x-relay-path": relayPath,
host: "localhost",
"x-custom": "preserved",
});
const strippedHeaders = stripRelayHeaders(originalHeaders);
expect(strippedHeaders.get("x-relay-target")).toBeNull();
expect(strippedHeaders.get("x-relay-path")).toBeNull();
expect(strippedHeaders.get("host")).toBeNull();
expect(strippedHeaders.get("x-custom")).toBe("preserved");
});
test("POST flow with body preservation", () => {
const req = new Request("http://test.com", {
method: "POST",
body: JSON.stringify({ data: "test" }),
headers: { "content-type": "application/json" },
});
const options = buildRelayRequest(req, "https://api.test.com/endpoint", new Headers());
expect(options.method).toBe("POST");
expect(options.body).toBeDefined();
});
test("error case: missing target", () => {
const target = null;
const relayPath = "/test";
const result = normalizeTargetUrl(target, relayPath);
expect(result).toBeNull();
});
});
-7
View File
@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
-3
View File
@@ -1,3 +0,0 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig();
+12 -33
View File
@@ -1,41 +1,20 @@
{ {
"name": "real-starter", "name": "edge-proxy-relay",
"version": "0.1.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "next dev", "dev": "bun --hot src/index.ts",
"build": "next build", "start": "bun src/index.ts",
"start": "next start", "test": "bun test",
"lint": "eslint", "test:watch": "bun test --watch",
"build:worker": "bunx opennextjs-cloudflare build", "typecheck": "tsc --noEmit",
"preview": "wrangler dev", "build": "bun build src/index.ts --outdir ./dist --target bun",
"deploy": "bun run build:worker && wrangler deploy" "build:worker": "bun build src/index.ts --outdir ./dist --target bun --format esm"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"next": "16.2.5",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
}, },
"dependencies": {},
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"bun-types": "^1.3.13", "bun-types": "^1.3.13",
"eslint": "^9", "typescript": "^5"
"eslint-config-next": "16.2.5",
"tailwindcss": "^4",
"typescript": "^5",
"@opennextjs/cloudflare": "latest",
"wrangler": "^4.90.0"
} }
} }
-7
View File
@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+4
View File
@@ -0,0 +1,4 @@
.claude/
.codegraph/
-219
View File
@@ -1,219 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
export default function Home() {
const [targetUrl, setTargetUrl] = useState("https://jsonplaceholder.typicode.com/posts/1");
const [method, setMethod] = useState("GET");
const [requestBody, setRequestBody] = useState(JSON.stringify({
title: 'foo',
body: 'bar',
userId: 1,
}, null, 2));
const [headerKey, setHeaderKey] = useState("");
const [headerValue, setHeaderValue] = useState("");
const [response, setResponse] = useState<any>(null);
const [loading, setLoading] = useState(false);
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
async function sendRequest() {
setLoading(true);
setResponse(null);
const headers: Record<string, string> = {
'x-relay-target': targetUrl,
'Content-Type': 'application/json'
};
if (headerKey && headerValue) {
headers[headerKey] = headerValue;
}
try {
const start = Date.now();
const fetchOptions: any = {
method,
headers
};
if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && requestBody) {
fetchOptions.body = requestBody;
}
const res = await fetch('/', fetchOptions);
const elapsed = Date.now() - start;
let responseText = '';
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const data = await res.json();
responseText = JSON.stringify(data, null, 2);
} else {
responseText = await res.text();
}
const headersObj: Record<string, string> = {};
res.headers.forEach((value, key) => {
headersObj[key] = value;
});
setResponse({
status: res.status,
time: `${elapsed}ms`,
body: responseText,
headers: JSON.stringify(headersObj, null, 2)
});
toast.success(`Request finished with status ${res.status}`);
} catch (err: any) {
setResponse({
status: 'Error',
body: err.message,
headers: '-'
});
toast.error("Request failed");
} finally {
setLoading(false);
}
}
return (
<div className="container mx-auto py-10 px-4 max-w-6xl">
<Toaster position="top-right" />
<div className="flex flex-col gap-8">
<div className="space-y-2 text-center sm:text-left">
<h1 className="text-4xl font-extrabold tracking-tight">Edge Proxy</h1>
<p className="text-muted-foreground">High-performance request relay powered by Vercel Edge.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
<Card className="lg:col-span-5 h-fit">
<CardHeader>
<CardTitle>Configuration</CardTitle>
<CardDescription>Set up your target request.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="url">Target URL</Label>
<Input
id="url"
placeholder="https://api.example.com"
value={targetUrl}
onChange={(e) => setTargetUrl(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Method</Label>
<Select value={method} onValueChange={(val) => setMethod(val ?? "GET")}>
<SelectTrigger>
<SelectValue placeholder="Select Method" />
</SelectTrigger>
<SelectContent>
{methods.map(m => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Header Key</Label>
<Input placeholder="x-api-key" value={headerKey} onChange={(e) => setHeaderKey(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Header Value</Label>
<Input placeholder="value" value={headerValue} onChange={(e) => setHeaderValue(e.target.value)} />
</div>
</div>
{!['GET', 'HEAD', 'OPTIONS'].includes(method) && (
<div className="space-y-2">
<Label htmlFor="body">Body (JSON)</Label>
<Textarea
id="body"
className="font-mono text-xs h-32"
value={requestBody}
onChange={(e) => setRequestBody(e.target.value)}
/>
</div>
)}
</CardContent>
<CardFooter>
<Button
className="w-full font-bold"
onClick={sendRequest}
disabled={loading}
>
{loading ? "Sending..." : "Send Request"}
</Button>
</CardFooter>
</Card>
<Card className="lg:col-span-7 flex flex-col min-h-125">
<CardHeader className="pb-3 border-b">
<div className="flex items-center justify-between">
<CardTitle>Response</CardTitle>
{response && (
<div className="flex gap-2">
<Badge variant={response.status >= 200 && response.status < 300 ? "default" : "destructive"}>
Status: {response.status}
</Badge>
<Badge variant="outline">{response.time}</Badge>
</div>
)}
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col pt-6">
{!response && !loading && (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground opacity-50 space-y-2">
<p>No response data.</p>
</div>
)}
{loading && (
<div className="flex-1 flex items-center justify-center">
<div className="animate-pulse space-y-2">
<div className="h-4 w-48 bg-muted rounded"></div>
<div className="h-4 w-32 bg-muted rounded"></div>
</div>
</div>
)}
{response && (
<Tabs defaultValue="body" className="flex-1 flex flex-col">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="body">Body</TabsTrigger>
<TabsTrigger value="headers">Headers</TabsTrigger>
</TabsList>
<TabsContent value="body" className="flex-1 mt-4">
<pre className="p-4 rounded-lg bg-zinc-950 text-emerald-400 font-mono text-[10px] sm:text-xs overflow-auto max-h-112.5 border shadow-inner">
{response.body}
</pre>
</TabsContent>
<TabsContent value="headers" className="flex-1 mt-4">
<pre className="p-4 rounded-lg bg-muted font-mono text-[10px] sm:text-xs overflow-auto max-h-112.5">
{response.headers}
</pre>
</TabsContent>
</Tabs>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

-130
View File
@@ -1,130 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
-23
View File
@@ -1,23 +0,0 @@
import type { Metadata } from "next";
import "./globals.css";
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
export const metadata: Metadata = {
title: "Edge Proxy Relay",
description: "Securely relay requests through Vercel Edge",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={cn("font-sans", geist.variable)}>
<body>{children}</body>
</html>
);
}
-60
View File
@@ -1,60 +0,0 @@
import {
buildRelayRequest,
createRelayResponse,
normalizeTargetUrl,
filterHeaders,
isAllowedTarget,
} from "@/lib/relay-utils";
async function handler(req: Request): Promise<Response> {
if (req.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
"Access-Control-Allow-Headers": "*",
"Access-Control-Max-Age": "86400",
},
});
}
const target = req.headers.get("x-relay-target");
const relayPath = req.headers.get("x-relay-path") || "/";
const targetUrl = normalizeTargetUrl(target, relayPath);
if (!targetUrl) {
return new Response(
JSON.stringify({ error: "Missing x-relay-target header" }),
{
status: 400,
headers: { "content-type": "application/json", "Access-Control-Allow-Origin": "*" },
},
);
}
if (!isAllowedTarget(targetUrl)) {
return new Response(
JSON.stringify({ error: "Target domain not allowed" }),
{
status: 403,
headers: { "content-type": "application/json", "Access-Control-Allow-Origin": "*" },
},
);
}
const headers = filterHeaders(new Headers(req.headers));
const fetchOptions = buildRelayRequest(req, headers);
const response = await fetch(targetUrl, fetchOptions);
return createRelayResponse(response);
}
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const DELETE = handler;
export const PATCH = handler;
export const HEAD = handler;
export const OPTIONS = handler;
-52
View File
@@ -1,52 +0,0 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
-58
View File
@@ -1,58 +0,0 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
-103
View File
@@ -1,103 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
-20
View File
@@ -1,20 +0,0 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
-20
View File
@@ -1,20 +0,0 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
-201
View File
@@ -1,201 +0,0 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
-25
View File
@@ -1,25 +0,0 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
-49
View File
@@ -1,49 +0,0 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
-82
View File
@@ -1,82 +0,0 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
-18
View File
@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+137
View File
@@ -0,0 +1,137 @@
/**
* Integration tests for the relay proxy server handlers.
*
* Tests the exported route handler functions directly,
* bypassing the HTTP server layer.
*/
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
// Import handler functions directly from index.ts
// Note: this will also start the Bun.serve() instance, which we allow.
import {
handleHealth,
handleDocs,
handleIndex,
getClientIP,
} from "./index";
describe("handleHealth", () => {
test("should return 200 with JSON body", async () => {
const response = handleHealth();
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/json");
});
test("should include status, uptime, and version fields", async () => {
const response = handleHealth();
const body = await response.json();
expect(body.status).toBe("ok");
expect(typeof body.uptime).toBe("number");
expect(body.version).toBe("1.0.0");
});
test("should include CORS header", () => {
const response = handleHealth();
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("handleDocs", () => {
test("should return 200 with HTML content", () => {
const response = handleDocs();
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toContain("text/html");
});
test("should include Edge Proxy Relay in the HTML", async () => {
const response = handleDocs();
const text = await response.text();
expect(text).toContain("Edge Proxy Relay");
expect(text).toContain("x-relay-target");
expect(text).toContain("WebSocket");
});
test("should include CORS header", () => {
const response = handleDocs();
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("handleIndex", () => {
test("should return 200 with HTML content", () => {
const response = handleIndex();
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toContain("text/html");
});
test("should include Edge Proxy Relay title", async () => {
const response = handleIndex();
const text = await response.text();
expect(text).toContain("Edge Proxy Relay");
expect(text).toContain("Server is running");
});
});
describe("getClientIP", () => {
const mockIpGetter = (address: string) => ({
requestIP(_req: Request) {
return { address, family: "IPv4" as const, port: 12345 };
},
});
const nullIpGetter = {
requestIP(_req: Request) {
return null;
},
};
test("should return x-forwarded-for when present", () => {
const req = new Request("http://localhost/test", {
headers: { "x-forwarded-for": "198.51.100.1" },
});
const ip = getClientIP(req, nullIpGetter);
expect(ip).toBe("198.51.100.1");
});
test("should use first IP from x-forwarded-for list", () => {
const req = new Request("http://localhost/test", {
headers: {
"x-forwarded-for": "198.51.100.1, 203.0.113.5, 192.0.2.10",
},
});
const ip = getClientIP(req, nullIpGetter);
expect(ip).toBe("198.51.100.1");
});
test("should fall back to cf-connecting-ip", () => {
const req = new Request("http://localhost/test", {
headers: { "cf-connecting-ip": "203.0.113.50" },
});
const ip = getClientIP(req, nullIpGetter);
expect(ip).toBe("203.0.113.50");
});
test("should prefer x-forwarded-for over cf-connecting-ip", () => {
const req = new Request("http://localhost/test", {
headers: {
"x-forwarded-for": "198.51.100.1",
"cf-connecting-ip": "203.0.113.50",
},
});
const ip = getClientIP(req, nullIpGetter);
expect(ip).toBe("198.51.100.1");
});
test("should fall back to server.requestIP when no headers present", () => {
const req = new Request("http://localhost/test");
const ip = getClientIP(req, mockIpGetter("10.0.0.42"));
expect(ip).toBe("10.0.0.42");
});
test("should return unknown when nothing is available", () => {
const req = new Request("http://localhost/test");
const ip = getClientIP(req, nullIpGetter);
expect(ip).toBe("unknown");
});
});
+558
View File
@@ -0,0 +1,558 @@
/**
* Edge Proxy Relay Pure Bun HTTP + WebSocket relay server.
*
* Forwards requests/responses to a target URL specified via the
* `x-relay-target` request header. Supports WebSocket upgrades
* when the target uses `ws://` or `wss://`.
*
* Environment Variables
* PORT Server listen port (default: 3000)
* RELAY_TIMEOUT_MS Upstream fetch timeout (default: 30_000)
* BODY_MAX_BYTES Maximum accepted request body (default: 1_048_576)
* RATE_LIMIT_MAX Max requests per sliding window (default: 100)
* RATE_LIMIT_WINDOW_MS Sliding window duration (default: 60_000)
*/
import {
normalizeTargetUrl,
isAllowedTarget,
filterRequestHeaders,
buildRelayRequest,
createRelayResponse,
classifyFetchError,
createErrorResponse,
createCorsPreflightResponse,
} from "./lib/relay-utils";
import { checkBodySize } from "./middleware/body-limiter";
import { createRateLimiter } from "./middleware/rate-limiter";
import { logRelayEvent } from "./middleware/logger";
import type { Server, ServerWebSocket } from "bun";
// ─── Configuration ──────────────────────────────────────────────────────────────
const PORT = Number.parseInt(process.env.PORT ?? "3000", 10);
const RELAY_TIMEOUT_MS = Number.parseInt(
process.env.RELAY_TIMEOUT_MS ?? "30000",
10,
);
const SERVER_START_TIME = Date.now();
const RELAY_VERSION = "1.0.0";
// ─── Middleware instances (singletons) ───────────────────────────────────────────
const rateLimiter = createRateLimiter({
maxRequests: Number.parseInt(process.env.RATE_LIMIT_MAX ?? "100", 10),
windowMs: Number.parseInt(
process.env.RATE_LIMIT_WINDOW_MS ?? "60000",
10,
),
});
// ─── WebSocket relay data type ──────────────────────────────────────────────────
interface WSRelayData {
target: string;
relayPath: string;
upstream?: WebSocket;
}
// ─── Route handlers ────────────────────────────────────────────────────────────
/** Health check endpoint: returns status, uptime, and version. */
function handleHealth(): Response {
return new Response(
JSON.stringify({
status: "ok",
uptime: Date.now() - SERVER_START_TIME,
version: RELAY_VERSION,
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
},
);
}
/** Simple embedded HTML documentation page. */
function handleDocs(): Response {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edge Proxy Relay Docs</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #e1e4e8; background: #0d1117; padding: 2rem; }
main { max-width: 800px; margin: 0 auto; }
h1 { font-size: 2rem; margin-bottom: 0.5rem; color: #58a6ff; }
h2 { font-size: 1.25rem; margin: 2rem 0 0.75rem; color: #c9d1d9; border-bottom: 1px solid #30363d; padding-bottom: 0.25rem; }
p, li { color: #8b949e; }
code { background: #161b22; padding: 0.2em 0.4em; border-radius: 4px; font-size: 0.9em; color: #f0f6fc; }
pre { background: #161b22; padding: 1rem; border-radius: 6px; overflow-x: auto; margin: 0.75rem 0; border: 1px solid #30363d; }
pre code { background: none; padding: 0; }
table { width: 100%; border-collapse: collapse; margin: 0.75rem 0; }
th, td { text-align: left; padding: 0.5rem 0.75rem; border: 1px solid #30363d; }
th { background: #161b22; color: #c9d1d9; }
ul { padding-left: 1.5rem; margin: 0.5rem 0; }
.endpoint { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem; margin: 1rem 0; }
.endpoint h3 { color: #58a6ff; font-family: monospace; margin-bottom: 0.5rem; }
.status { color: #3fb950; }
a { color: #58a6ff; }
</style>
</head>
<body>
<main>
<h1>Edge Proxy Relay</h1>
<p>Forward HTTP and WebSocket requests to any target server via the <code>x-relay-target</code> header.</p>
<h2>Endpoints</h2>
<div class="endpoint">
<h3>GET /health</h3>
<p>Health check. Returns <span class="status">200 OK</span> with server status, uptime, and version.</p>
</div>
<div class="endpoint">
<h3>GET /docs</h3>
<p>This page.</p>
</div>
<div class="endpoint">
<h3>Any Path (Catch-all Relay)</h3>
<p>Send a request with the <code>x-relay-target</code> header and this proxy forwards it.</p>
</div>
<h2>Usage HTTP Relay</h2>
<pre><code>curl -s \\
-H "x-relay-target: https://httpbin.org" \\
-H "x-relay-path: /get" \\
"https://your-proxy.example/any/path"</code></pre>
<table>
<tr><th>Header</th><th>Required</th><th>Description</th></tr>
<tr><td><code>x-relay-target</code></td><td>Yes</td><td>Base URL of the upstream (http:// or https://)</td></tr>
<tr><td><code>x-relay-path</code></td><td>No</td><td>Path to append (default: <code>/</code>)</td></tr>
</table>
<h2>Usage WebSocket Relay</h2>
<pre><code>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);</code></pre>
<h2>Status Codes</h2>
<table>
<tr><th>Code</th><th>Meaning</th></tr>
<tr><td>204</td><td>CORS preflight success (OPTIONS)</td></tr>
<tr><td>400</td><td>Missing <code>x-relay-target</code> header</td></tr>
<tr><td>403</td><td>Target blocked (SSRF protection / not allowed)</td></tr>
<tr><td>413</td><td>Request body exceeds size limit</td></tr>
<tr><td>429</td><td>Rate limit exceeded</td></tr>
<tr><td>502</td><td>Upstream network / DNS error</td></tr>
<tr><td>504</td><td>Upstream timeout</td></tr>
</table>
</main>
</body>
</html>`;
return new Response(html, {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Access-Control-Allow-Origin": "*",
},
});
}
/** Minimal status page shown at the root `/`. */
function handleIndex(): Response {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edge Proxy Relay</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #e1e4e8; background: #0d1117; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
main { text-align: center; }
h1 { font-size: 2rem; color: #58a6ff; margin-bottom: 0.5rem; }
p { color: #8b949e; }
a { color: #58a6ff; }
.status { color: #3fb950; }
</style>
</head>
<body>
<main>
<h1>Edge Proxy Relay</h1>
<p class="status">Server is running</p>
<p><a href="/health">/health</a> &middot; <a href="/docs">/docs</a></p>
</main>
</body>
</html>`;
return new Response(html, {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
},
});
}
// ─── HTTP Relay Logic ──────────────────────────────────────────────────────────
/**
* Get the client IP address from the request.
* Tries `x-forwarded-for` first, then `cf-connecting-ip`, then falls back
* to the direct connection address from `server.requestIP()`.
*/
function getClientIP(
req: Request,
ipGetter: { requestIP(req: Request): { address: string } | null },
): string {
const forwarded = req.headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
const cfIp = req.headers.get("cf-connecting-ip");
if (cfIp) return cfIp;
const remote = ipGetter.requestIP(req);
if (remote) return remote.address;
return "unknown";
}
/**
* Core HTTP relay handler.
*
* Expects `x-relay-target` header to determine the upstream URL.
* Applies middleware (body size check, rate limiting, logging) and
* proxies the request while filtering sensitive headers.
*/
async function handleRelay(
req: Request,
ipGetter: { requestIP(req: Request): { address: string } | null },
): Promise<Response> {
const startTime = performance.now();
const method = req.method;
const clientIP = getClientIP(req, ipGetter);
const requestUrl = req.url;
// ── Pre-flight CORS ──────────────────────────────────────────────
if (method === "OPTIONS") {
return createCorsPreflightResponse();
}
// ── Middleware: Body size check ──────────────────────────────────
const bodyError = checkBodySize(req);
if (bodyError) {
logRelayEvent({
method,
url: requestUrl,
status: bodyError.status,
durationMs: Math.round(performance.now() - startTime),
ip: clientIP,
});
return bodyError;
}
// ── Middleware: Rate limiting ────────────────────────────────────
const rateCheck = rateLimiter.check(clientIP);
if (!rateCheck.allowed) {
logRelayEvent({
method,
url: requestUrl,
status: 429,
durationMs: Math.round(performance.now() - startTime),
error: "rate_limit_exceeded",
ip: clientIP,
});
return new Response(
JSON.stringify({
error: true,
code: "RATE_LIMITED",
message: "Too many requests",
retryAfterMs: rateCheck.retryAfterMs,
}),
{
status: 429,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Retry-After": String(
Math.ceil((rateCheck.retryAfterMs ?? 60_000) / 1000),
),
},
},
);
}
// ── Extract relay parameters from headers ───────────────────────
const target = req.headers.get("x-relay-target");
const relayPath = req.headers.get("x-relay-path") ?? "/";
// ── SSRF: Normalize and validate target URL ──────────────────────
const targetUrl = normalizeTargetUrl(target, relayPath);
if (!targetUrl) {
logRelayEvent({
method,
url: requestUrl,
status: 400,
durationMs: Math.round(performance.now() - startTime),
error: "missing_target_header",
ip: clientIP,
});
return createErrorResponse({
code: "INVALID_TARGET",
status: 400,
message: "Missing or invalid x-relay-target header",
});
}
if (!isAllowedTarget(targetUrl)) {
logRelayEvent({
method,
url: requestUrl,
status: 403,
durationMs: Math.round(performance.now() - startTime),
error: "target_not_allowed",
ip: clientIP,
});
return createErrorResponse({
code: "SSRF_BLOCKED",
status: 403,
message: "Target domain not allowed",
});
}
// ── Build the upstream request ───────────────────────────────────
const filteredHeaders = filterRequestHeaders(req.headers);
const fetchOptions = buildRelayRequest(
req,
filteredHeaders,
RELAY_TIMEOUT_MS,
);
const targetUrlString = targetUrl.toString();
// ── Execute upstream fetch ───────────────────────────────────────
let response: Response;
try {
response = await fetch(targetUrlString, fetchOptions);
} catch (err) {
const classified = classifyFetchError(err);
logRelayEvent({
method,
url: requestUrl,
status: classified.status,
durationMs: Math.round(performance.now() - startTime),
error: classified.message,
targetUrl: targetUrlString,
ip: clientIP,
});
return createErrorResponse(classified);
}
// ── Build relay response ─────────────────────────────────────────
const relayedResponse = createRelayResponse(response);
logRelayEvent({
method,
url: requestUrl,
status: relayedResponse.status,
durationMs: Math.round(performance.now() - startTime),
targetUrl: targetUrlString,
ip: clientIP,
});
return relayedResponse;
}
// ─── WebSocket Relay Logic ─────────────────────────────────────────────────────
/**
* Upgrade an HTTP request to a WebSocket and relay bidirectionally to the
* target URL specified in the `x-relay-target` header.
*
* Returns `undefined` when the upgrade has been accepted (Bun takes over),
* or a Response when the upgrade failed or the target is invalid.
*/
function handleWebSocketUpgrade(
req: Request,
srv: Server<WSRelayData>,
): Response | undefined {
const target = req.headers.get("x-relay-target");
if (!target) return undefined;
const isWS =
target.startsWith("ws://") || target.startsWith("wss://");
if (!isWS) return undefined;
const relayPath = req.headers.get("x-relay-path") ?? "/";
// Normalize the target URL to verify it's valid
const normalized = normalizeTargetUrl(target, relayPath);
if (!normalized) return undefined;
if (!isAllowedTarget(new URL(normalized.toString()))) return undefined;
const targetUrl = normalized.toString();
const upgraded = srv.upgrade(req, {
data: { target: targetUrl, relayPath },
});
if (!upgraded) {
return new Response("WebSocket upgrade failed", { status: 400 });
}
// Returning undefined signals Bun that the upgrade was handled
return undefined;
}
// ─── Server ─────────────────────────────────────────────────────────────────────
const server: Server<WSRelayData> = Bun.serve<WSRelayData>({
port: PORT,
development: {
hmr: true,
console: true,
},
async fetch(req: Request): Promise<Response | undefined> {
const url = new URL(req.url);
// Static routes
if (url.pathname === "/health") return handleHealth();
if (url.pathname === "/docs") return handleDocs();
if (url.pathname === "/" && req.method === "GET") return handleIndex();
// WebSocket upgrade check — if the target is ws:// or wss://,
// attempt to upgrade and relay. This must happen before the
// general HTTP relay.
if (
req.method === "GET" &&
req.headers.get("upgrade")?.toLowerCase() === "websocket"
) {
const wsResult = handleWebSocketUpgrade(req, server);
if (wsResult === undefined) {
// Upgrade was handled by Bun — return undefined
return undefined;
}
return wsResult;
}
// Generic HTTP relay
return handleRelay(req, server);
},
websocket: {
open(ws: ServerWebSocket<WSRelayData>) {
const { target } = ws.data;
logRelayEvent({
method: "WS",
url: target,
status: 101,
durationMs: 0,
targetUrl: target,
});
// Connect to the upstream WebSocket
const upstream = new WebSocket(target);
upstream.onopen = () => {
// Connection established — ready for bidirectional relay
};
upstream.onmessage = (event: MessageEvent) => {
const data = event.data;
if (typeof data === "string") {
ws.sendText(data);
} else if (data instanceof ArrayBuffer) {
ws.sendBinary(new Uint8Array(data));
} else if (data instanceof Blob) {
data.arrayBuffer().then((buf) => {
ws.sendBinary(new Uint8Array(buf));
});
} else {
ws.sendBinary(data as unknown as Uint8Array);
}
};
upstream.onerror = () => {
ws.close(1011, "Upstream WebSocket error");
};
upstream.onclose = (event: CloseEvent) => {
ws.close(event.code || 1000, event.reason || "Upstream closed");
};
// Store the upstream so we can close it on client disconnect
ws.data.upstream = upstream;
},
message(ws: ServerWebSocket<WSRelayData>, message: string | Buffer<ArrayBuffer>) {
const upstream = ws.data.upstream;
if (upstream && upstream.readyState === WebSocket.OPEN) {
if (typeof message === "string") {
upstream.send(message);
} else {
upstream.send(message);
}
}
},
close(ws: ServerWebSocket<WSRelayData>, _code: number, _reason: string) {
const upstream = ws.data.upstream;
if (upstream) {
try {
upstream.close();
} catch {
// Already closed
}
}
},
drain(_ws: ServerWebSocket<WSRelayData>) {
// Backpressure not implemented in this minimal relay
},
},
});
// ─── Startup ───────────────────────────────────────────────────────────────────
console.log(
`[relay] Edge Proxy Relay v${RELAY_VERSION} listening on http://localhost:${server.port}`,
);
// ─── Graceful Shutdown ─────────────────────────────────────────────────────────
const shutdownHandler = (signal: string) => {
console.log(`\n[relay] Received ${signal}, shutting down gracefully...`);
server.stop();
process.exit(0);
};
process.on("SIGTERM", () => shutdownHandler("SIGTERM"));
process.on("SIGINT", () => shutdownHandler("SIGINT"));
// ─── Exports (for testing) ─────────────────────────────────────────────────────
export type { WSRelayData };
export {
server,
handleHealth,
handleDocs,
handleIndex,
handleRelay,
getClientIP,
};
+836 -65
View File
@@ -1,85 +1,856 @@
/**
* Relay utilities test suite.
*
* Covers URL normalisation, SSRF protection, header filtering,
* request building, error classification, and response creation.
*/
import { test, expect, describe } from "bun:test"; import { test, expect, describe } from "bun:test";
import { import {
normalizeTargetUrl, normalizeTargetUrl,
filterHeaders, filterHeaders,
filterRequestHeaders,
filterResponseHeaders,
shouldSendBody, shouldSendBody,
isAllowedTarget, isAllowedTarget,
isPrivateIp,
classifyFetchError,
createErrorResponse,
createCorsPreflightResponse,
buildRelayRequest,
createRelayResponse,
RelayError,
} from "./relay-utils"; } from "./relay-utils";
describe("relay-utils", () => { // ─── NormalizeTargetUrl ────────────────────────────────────────────────────
describe("normalizeTargetUrl", () => {
test("should combine target and path", () => {
expect(normalizeTargetUrl("https://example.com", "/api/v1")).toBe("https://example.com/api/v1");
});
test("should handle trailing slash in target", () => { describe("normalizeTargetUrl", () => {
expect(normalizeTargetUrl("https://example.com/", "/api/v1")).toBe("https://example.com/api/v1"); test("should combine target and path — returns URL object", () => {
}); const result = normalizeTargetUrl("https://example.com", "/api/v1");
expect(result).toBeInstanceOf(URL);
test("should return null if target is missing", () => { expect(result!.href).toBe("https://example.com/api/v1");
expect(normalizeTargetUrl(null, "/api/v1")).toBe(null);
});
}); });
describe("filterHeaders", () => { test("should handle trailing slash in target", () => {
test("should remove blocked headers", () => { const result = normalizeTargetUrl("https://example.com/", "/api/v1");
const headers = new Headers({ expect(result!.href).toBe("https://example.com/api/v1");
"content-type": "application/json",
"cookie": "secret=123",
"x-vercel-id": "123",
"cf-ray": "123",
"host": "localhost",
"x-relay-target": "test",
});
const filtered = filterHeaders(headers);
expect(filtered.has("content-type")).toBe(true);
expect(filtered.has("cookie")).toBe(false);
expect(filtered.has("x-vercel-id")).toBe(false);
expect(filtered.has("cf-ray")).toBe(false);
expect(filtered.has("host")).toBe(false);
expect(filtered.has("x-relay-target")).toBe(false);
});
test("should remove headers starting with blocked prefixes", () => {
const headers = new Headers({
"x-vercel-custom": "val",
"cf-custom": "val",
"x-forwarded-for": "1.1.1.1",
});
const filtered = filterHeaders(headers);
expect(filtered.has("x-vercel-custom")).toBe(false);
expect(filtered.has("cf-custom")).toBe(false);
expect(filtered.has("x-forwarded-for")).toBe(false);
});
}); });
describe("shouldSendBody", () => { test("should handle trailing slashes in target", () => {
test("should return false for GET and HEAD", () => { const result = normalizeTargetUrl("https://example.com///", "/api/v1");
expect(shouldSendBody("GET")).toBe(false); expect(result!.href).toBe("https://example.com/api/v1");
expect(shouldSendBody("HEAD")).toBe(false);
});
test("should return true for POST, PUT, DELETE, PATCH", () => {
expect(shouldSendBody("POST")).toBe(true);
expect(shouldSendBody("PUT")).toBe(true);
expect(shouldSendBody("DELETE")).toBe(true);
expect(shouldSendBody("PATCH")).toBe(true);
});
}); });
describe("isAllowedTarget", () => { test("should return null if target is missing", () => {
test("should allow http and https", () => { expect(normalizeTargetUrl(null, "/api/v1")).toBe(null);
expect(isAllowedTarget("https://example.com")).toBe(true); });
expect(isAllowedTarget("http://example.com")).toBe(true);
});
test("should reject other protocols", () => { test("should return null if target is empty string", () => {
expect(isAllowedTarget("ftp://example.com")).toBe(false); expect(normalizeTargetUrl("", "/api/v1")).toBe(null);
expect(isAllowedTarget("javascript:alert(1)")).toBe(false); });
});
test("should reject invalid URLs", () => { test("should return null if target is whitespace-only", () => {
expect(isAllowedTarget("not-a-url")).toBe(false); expect(normalizeTargetUrl(" ", "/api/v1")).toBe(null);
});
test("should keep target without trailing slash", () => {
const result = normalizeTargetUrl("https://httpbin.org", "/get");
expect(result!.href).toBe("https://httpbin.org/get");
});
test("should append relay path without leading slash", () => {
const result = normalizeTargetUrl("https://example.com", "api/users");
expect(result!.href).toBe("https://example.com/api/users");
});
test("should handle nested paths", () => {
const result = normalizeTargetUrl(
"https://api.example.com",
"/v1/users/123/profile",
);
expect(result!.href).toBe(
"https://api.example.com/v1/users/123/profile",
);
});
test("should merge query parameters from target URL", () => {
const result = normalizeTargetUrl(
"https://example.com?source=proxy",
"/path",
);
expect(result!.href).toBe("https://example.com/path?source=proxy");
});
test("should merge query parameters from relay path", () => {
const result = normalizeTargetUrl(
"https://example.com",
"/path?format=json",
);
expect(result!.href).toBe("https://example.com/path?format=json");
});
test("should merge query params from both target and relay path", () => {
const result = normalizeTargetUrl(
"https://example.com?source=proxy",
"/path?format=json",
);
const href = result!.href;
expect(href).toContain("source=proxy");
expect(href).toContain("format=json");
});
test("should return null for invalid target URL", () => {
expect(normalizeTargetUrl("not-a-valid-url", "/path")).toBe(null);
});
test("should handle target with existing path", () => {
const result = normalizeTargetUrl("https://example.com/base", "/new");
expect(result!.href).toBe("https://example.com/base/new");
});
test("should handle target with trailing path", () => {
const result = normalizeTargetUrl(
"https://api.example.com/v1/",
"/users",
);
expect(result!.href).toBe("https://api.example.com/v1/users");
});
test("should preserve port in target", () => {
const result = normalizeTargetUrl(
"https://localhost:8443",
"/api/test",
);
expect(result!.port).toBe("8443");
expect(result!.href).toBe("https://localhost:8443/api/test");
});
});
// ─── IsPrivateIp ───────────────────────────────────────────────────────────
describe("isPrivateIp", () => {
test("should return true for IPv4 loopback", () => {
expect(isPrivateIp("127.0.0.1")).toBe(true);
expect(isPrivateIp("127.255.255.255")).toBe(true);
});
test("should return true for 10.x.x.x (private class A)", () => {
expect(isPrivateIp("10.0.0.1")).toBe(true);
expect(isPrivateIp("10.255.255.255")).toBe(true);
});
test("should return true for 192.168.x.x (private class C)", () => {
expect(isPrivateIp("192.168.0.1")).toBe(true);
expect(isPrivateIp("192.168.255.255")).toBe(true);
});
test("should return true for 172.16-31.x.x (private class B)", () => {
expect(isPrivateIp("172.16.0.1")).toBe(true);
expect(isPrivateIp("172.31.255.255")).toBe(true);
});
test("should return false for 172.15.x.x (outside private range)", () => {
expect(isPrivateIp("172.15.0.1")).toBe(false);
});
test("should return false for 172.32.x.x (outside private range)", () => {
expect(isPrivateIp("172.32.0.1")).toBe(false);
});
test("should return true for link-local 169.254.x.x", () => {
expect(isPrivateIp("169.254.1.1")).toBe(true);
});
test("should return true for IPv6 loopback", () => {
expect(isPrivateIp("::1")).toBe(true);
});
test("should return true for IPv6 unspecified", () => {
expect(isPrivateIp("::")).toBe(true);
});
test("should return true for IPv6 link-local", () => {
expect(isPrivateIp("fe80::1")).toBe(true);
expect(isPrivateIp("FE80::")).toBe(true);
});
test("should return true for IPv6 unique local", () => {
expect(isPrivateIp("fd00::1")).toBe(true);
expect(isPrivateIp("fc00::1")).toBe(true);
});
test("should return false for public IPs", () => {
expect(isPrivateIp("8.8.8.8")).toBe(false);
expect(isPrivateIp("1.1.1.1")).toBe(false);
expect(isPrivateIp("93.184.216.34")).toBe(false);
});
test("should return false for public hostnames", () => {
expect(isPrivateIp("example.com")).toBe(false);
expect(isPrivateIp("google.com")).toBe(false);
});
test("should return true for localhost", () => {
expect(isPrivateIp("localhost")).toBe(true);
expect(isPrivateIp("LOCALHOST")).toBe(true);
});
test("should return true for metadata endpoints", () => {
expect(isPrivateIp("169.254.169.254")).toBe(true);
expect(isPrivateIp("metadata.google.internal")).toBe(true);
expect(isPrivateIp("metadata.internal")).toBe(true);
});
test("should return true for .local and .internal hostnames", () => {
expect(isPrivateIp("myhost.local")).toBe(true);
expect(isPrivateIp("service.internal")).toBe(true);
});
test("should return true for 0.0.0.0", () => {
expect(isPrivateIp("0.0.0.0")).toBe(true);
});
test("should handle empty string", () => {
expect(isPrivateIp("")).toBe(false);
});
});
// ─── IsAllowedTarget ───────────────────────────────────────────────────────
describe("isAllowedTarget", () => {
test("should allow https and http URLs", () => {
expect(isAllowedTarget(new URL("https://example.com"))).toBe(true);
expect(isAllowedTarget(new URL("http://example.com"))).toBe(true);
});
test("should reject other protocols", () => {
expect(isAllowedTarget(new URL("ftp://example.com"))).toBe(false);
expect(isAllowedTarget(new URL("javascript:alert(1)"))).toBe(false);
expect(isAllowedTarget(new URL("file:///etc/passwd"))).toBe(false);
});
test("should reject private IPs", () => {
expect(isAllowedTarget(new URL("http://127.0.0.1:8080/api"))).toBe(
false,
);
expect(isAllowedTarget(new URL("http://192.168.1.1"))).toBe(false);
expect(isAllowedTarget(new URL("http://10.0.0.1"))).toBe(false);
});
test("should reject localhost", () => {
expect(isAllowedTarget(new URL("http://localhost:3000"))).toBe(false);
});
test("should reject metadata endpoints", () => {
expect(
isAllowedTarget(new URL("http://169.254.169.254/latest/meta-data/")),
).toBe(false);
});
test("should allow public hosts on standard ports", () => {
expect(isAllowedTarget(new URL("https://api.github.com"))).toBe(true);
expect(isAllowedTarget(new URL("https://httpbin.org/get"))).toBe(true);
});
});
// ─── FilterRequestHeaders ──────────────────────────────────────────────────
describe("filterRequestHeaders", () => {
test("should keep allowed headers and remove blocked ones", () => {
const headers = new Headers({
"content-type": "application/json",
cookie: "secret=123",
"x-vercel-id": "abc123",
"cf-ray": "def456",
host: "localhost",
"x-relay-target": "test",
}); });
const filtered = filterRequestHeaders(headers);
expect(filtered.get("content-type")).toBe("application/json");
expect(filtered.has("cookie")).toBe(false);
expect(filtered.has("x-vercel-id")).toBe(false);
expect(filtered.has("cf-ray")).toBe(false);
expect(filtered.has("host")).toBe(false);
expect(filtered.has("x-relay-target")).toBe(false);
});
test("should remove headers with blocked prefixes", () => {
const headers = new Headers({
"x-vercel-custom": "val",
"cf-custom": "val",
"x-forwarded-for": "1.1.1.1",
"x-forwarded-host": "example.com",
"x-forwarded-proto": "https",
});
const filtered = filterRequestHeaders(headers);
expect(filtered.has("x-vercel-custom")).toBe(false);
expect(filtered.has("cf-custom")).toBe(false);
expect(filtered.has("x-forwarded-for")).toBe(false);
expect(filtered.has("x-forwarded-host")).toBe(false);
expect(filtered.has("x-forwarded-proto")).toBe(false);
});
test("should preserve non-blocked headers", () => {
const headers = new Headers({
authorization: "Bearer token-123",
"x-custom": "custom-value",
"x-request-id": "req-abc",
});
const filtered = filterRequestHeaders(headers);
expect(filtered.get("authorization")).toBe("Bearer token-123");
expect(filtered.get("x-custom")).toBe("custom-value");
expect(filtered.get("x-request-id")).toBe("req-abc");
});
test("should handle case-insensitive header matching", () => {
const headers = new Headers({
Host: "example.com",
"X-Vercel-Id": "abc123",
"CF-Ray": "def456",
});
const filtered = filterRequestHeaders(headers);
expect(filtered.has("Host")).toBe(false);
expect(filtered.has("X-Vercel-Id")).toBe(false);
expect(filtered.has("CF-Ray")).toBe(false);
});
test("should not mutate the original headers", () => {
const headers = new Headers({ cookie: "secret", "x-custom": "val" });
const filtered = filterRequestHeaders(headers);
expect(headers.has("cookie")).toBe(true);
expect(filtered.has("cookie")).toBe(false);
});
test("should not add any new headers", () => {
const headers = new Headers({ "x-custom": "val" });
const filtered = filterRequestHeaders(headers);
expect(filtered.get("x-custom")).toBe("val");
expect(Array.from(filtered).length).toBe(1);
});
test("should block hop-by-hop headers", () => {
const headers = new Headers({
connection: "close",
"transfer-encoding": "chunked",
"proxy-authorization": "basic xyz",
});
const filtered = filterRequestHeaders(headers);
expect(filtered.has("connection")).toBe(false);
expect(filtered.has("transfer-encoding")).toBe(false);
expect(filtered.has("proxy-authorization")).toBe(false);
});
test("should block x-real-ip and forwarded and via", () => {
const headers = new Headers({
"x-real-ip": "10.0.0.1",
forwarded: "for=192.0.2.60",
via: "1.1 proxy",
});
const filtered = filterRequestHeaders(headers);
expect(filtered.has("x-real-ip")).toBe(false);
expect(filtered.has("forwarded")).toBe(false);
expect(filtered.has("via")).toBe(false);
});
});
// ─── filterHeaders (backward compatibility alias) ──────────────────────────
describe("filterHeaders (deprecated alias)", () => {
test("should be the same function as filterRequestHeaders", () => {
expect(filterHeaders).toBe(filterRequestHeaders);
});
test("should work identically to filterRequestHeaders", () => {
const headers = new Headers({
"content-type": "application/json",
cookie: "secret",
});
expect(filterHeaders(headers).get("content-type")).toBe(
"application/json",
);
expect(filterHeaders(headers).has("cookie")).toBe(false);
});
});
// ─── FilterResponseHeaders ─────────────────────────────────────────────────
describe("filterResponseHeaders", () => {
test("should remove blocked response headers", () => {
const headers = new Headers({
"content-type": "application/json",
"set-cookie": "session=abc",
"transfer-encoding": "chunked",
"keep-alive": "timeout=5",
connection: "keep-alive",
});
const filtered = filterResponseHeaders(headers);
expect(filtered.get("content-type")).toBe("application/json");
expect(filtered.has("set-cookie")).toBe(false);
expect(filtered.has("transfer-encoding")).toBe(false);
expect(filtered.has("keep-alive")).toBe(false);
expect(filtered.has("connection")).toBe(false);
});
test("should add CORS headers", () => {
const headers = new Headers();
const filtered = filterResponseHeaders(headers);
expect(filtered.get("Access-Control-Allow-Origin")).toBe("*");
expect(filtered.get("Access-Control-Allow-Methods")).toBeTruthy();
expect(filtered.get("Access-Control-Allow-Headers")).toBe("*");
});
});
// ─── ShouldSendBody ────────────────────────────────────────────────────────
describe("shouldSendBody", () => {
test("should return false for GET", () => {
expect(shouldSendBody("GET")).toBe(false);
});
test("should return false for HEAD", () => {
expect(shouldSendBody("HEAD")).toBe(false);
});
test("should return false for CONNECT", () => {
expect(shouldSendBody("CONNECT")).toBe(false);
});
test("should return true for POST", () => {
expect(shouldSendBody("POST")).toBe(true);
});
test("should return true for PUT", () => {
expect(shouldSendBody("PUT")).toBe(true);
});
test("should return true for PATCH", () => {
expect(shouldSendBody("PATCH")).toBe(true);
});
test("should return true for DELETE", () => {
expect(shouldSendBody("DELETE")).toBe(true);
});
test("should return true for OPTIONS", () => {
expect(shouldSendBody("OPTIONS")).toBe(true);
});
test("should handle lowercase methods", () => {
expect(shouldSendBody("get")).toBe(false);
expect(shouldSendBody("head")).toBe(false);
expect(shouldSendBody("connect")).toBe(false);
expect(shouldSendBody("post")).toBe(true);
expect(shouldSendBody("put")).toBe(true);
expect(shouldSendBody("patch")).toBe(true);
expect(shouldSendBody("delete")).toBe(true);
});
});
// ─── BuildRelayRequest ─────────────────────────────────────────────────────
describe("buildRelayRequest", () => {
test("should set correct method", () => {
const req = new Request("http://test.com", { method: "POST" });
const headers = new Headers();
const result = buildRelayRequest(req, headers);
expect(result.method).toBe("POST");
});
test("should set provided headers", () => {
const req = new Request("http://test.com");
const headers = new Headers({ "x-custom": "value" });
const result = buildRelayRequest(req, headers);
expect(result.headers).toBe(headers);
});
test("should omit body for GET", () => {
const req = new Request("http://test.com", { method: "GET" });
const headers = new Headers();
const result = buildRelayRequest(req, headers);
expect(result.body).toBeUndefined();
});
test("should omit body for HEAD", () => {
const req = new Request("http://test.com", { method: "HEAD" });
const headers = new Headers();
const result = buildRelayRequest(req, headers);
expect(result.body).toBeUndefined();
});
test("should omit body for CONNECT", () => {
const req = new Request("http://test.com", { method: "CONNECT" });
const headers = new Headers();
const result = buildRelayRequest(req, headers);
expect(result.body).toBeUndefined();
});
test("should include body and duplex for POST", () => {
const body = JSON.stringify({ test: true });
const req = new Request("http://test.com", {
method: "POST",
body,
});
const headers = new Headers();
const result = buildRelayRequest(req, headers) as RequestInit & {
duplex?: string;
};
expect(result.body).toBeDefined();
expect(result.duplex).toBe("half");
});
test("should include body for PUT", () => {
const body = JSON.stringify({ update: true });
const req = new Request("http://test.com", { method: "PUT", body });
const headers = new Headers();
const result = buildRelayRequest(req, headers);
expect(result.body).toBeDefined();
});
test("should include body for PATCH", () => {
const body = JSON.stringify({ patch: true });
const req = new Request("http://test.com", { method: "PATCH", body });
const headers = new Headers();
const result = buildRelayRequest(req, headers);
expect(result.body).toBeDefined();
});
test("should include body for DELETE", () => {
const body = JSON.stringify({ delete: true });
const req = new Request("http://test.com", { method: "DELETE", body });
const headers = new Headers();
const result = buildRelayRequest(req, headers);
expect(result.body).toBeDefined();
});
test("should include AbortSignal with default timeout", () => {
const req = new Request("http://test.com", { method: "GET" });
const headers = new Headers();
const result = buildRelayRequest(req, headers);
expect(result.signal).toBeDefined();
expect(result.signal).toBeInstanceOf(AbortSignal);
});
test("should use provided timeout", () => {
const req = new Request("http://test.com", { method: "GET" });
const headers = new Headers();
const result = buildRelayRequest(req, headers, 5000);
expect(result.signal).toBeDefined();
});
test("should handle lowercase method correctly", () => {
const req = new Request("http://test.com", { method: "post", body: "data" });
const headers = new Headers();
const result = buildRelayRequest(req, headers) as RequestInit & {
duplex?: string;
};
expect(result.method).toBe("POST");
expect(result.body).toBeDefined();
expect(result.duplex).toBe("half");
});
});
// ─── ClassifyFetchError ────────────────────────────────────────────────────
describe("classifyFetchError", () => {
test("should classify AbortError as TIMEOUT / 504", () => {
const error = new DOMException("The operation was aborted", "AbortError");
const result = classifyFetchError(error);
expect(result.code).toBe("TIMEOUT");
expect(result.status).toBe(504);
expect(result.message).toBe("Upstream timed out");
});
test("should classify TimeoutError as TIMEOUT / 504", () => {
const error = new DOMException("Timeout", "TimeoutError");
const result = classifyFetchError(error);
expect(result.code).toBe("TIMEOUT");
expect(result.status).toBe(504);
});
test("should classify DNS resolution failures", () => {
const error = new TypeError(
"fetch failed: DNS resolution failed for host",
);
const result = classifyFetchError(error);
expect(result.code).toBe("DNS_FAILURE");
expect(result.status).toBe(502);
});
test("should classify ENOTFOUND as DNS failure", () => {
const error = new TypeError("getaddrinfo ENOTFOUND example.com");
const result = classifyFetchError(error);
expect(result.code).toBe("DNS_FAILURE");
});
test("should classify hostname resolution failures", () => {
const error = new TypeError("fetch failed: hostname not found");
const result = classifyFetchError(error);
expect(result.code).toBe("DNS_FAILURE");
});
test("should classify connection refused", () => {
const error = new TypeError("fetch failed: connection refused");
const result = classifyFetchError(error);
expect(result.code).toBe("CONNECTION_REFUSED");
expect(result.status).toBe(502);
});
test("should classify ECONNREFUSED", () => {
const error = new TypeError(
"connect ECONNREFUSED 127.0.0.1:8080",
);
const result = classifyFetchError(error);
expect(result.code).toBe("CONNECTION_REFUSED");
});
test("should classify generic network errors", () => {
const error = new TypeError("fetch failed: network error");
const result = classifyFetchError(error);
expect(result.code).toBe("NETWORK_ERROR");
expect(result.status).toBe(502);
});
test("should classify ECONNRESET as network error", () => {
const error = new TypeError("read ECONNRESET");
const result = classifyFetchError(error);
expect(result.code).toBe("NETWORK_ERROR");
});
test("should classify ECONNABORTED as network error", () => {
const error = new TypeError("write ECONNABORTED");
const result = classifyFetchError(error);
expect(result.code).toBe("NETWORK_ERROR");
});
test("should classify ENETUNREACH as network error", () => {
const error = new TypeError("connect ENETUNREACH 10.0.0.1:80");
const result = classifyFetchError(error);
expect(result.code).toBe("NETWORK_ERROR");
});
test("should pass through RelayError unchanged", () => {
const relayError = new RelayError(
"SSRF_BLOCKED",
403,
"Target is not allowed",
);
const result = classifyFetchError(relayError);
expect(result.code).toBe("SSRF_BLOCKED");
expect(result.status).toBe(403);
expect(result.message).toBe("Target is not allowed");
});
test("should handle RelayError with TIMEOUT code", () => {
const relayError = new RelayError("TIMEOUT", 504, "Custom timeout");
const result = classifyFetchError(relayError);
expect(result.code).toBe("TIMEOUT");
expect(result.status).toBe(504);
});
test("should handle unknown Error objects", () => {
const error = new Error("Something completely unexpected");
const result = classifyFetchError(error);
expect(result.code).toBe("NETWORK_ERROR");
expect(result.status).toBe(502);
expect(result.message).toBe("Unknown upstream error");
});
test("should handle non-Error thrown values", () => {
const result = classifyFetchError("just a string");
expect(result.code).toBe("NETWORK_ERROR");
expect(result.status).toBe(502);
});
test("should handle null thrown value", () => {
const result = classifyFetchError(null);
expect(result.code).toBe("NETWORK_ERROR");
expect(result.status).toBe(502);
});
test("should handle undefined thrown value", () => {
const result = classifyFetchError(undefined);
expect(result.code).toBe("NETWORK_ERROR");
expect(result.status).toBe(502);
});
test("should handle generic TypeError that doesn't match known patterns", () => {
const error = new TypeError("some random type error");
const result = classifyFetchError(error);
expect(result.code).toBe("NETWORK_ERROR");
expect(result.status).toBe(502);
});
});
// ─── CreateErrorResponse ───────────────────────────────────────────────────
describe("createErrorResponse", () => {
test("should return correct status and JSON body", async () => {
const result = createErrorResponse({
code: "TIMEOUT",
status: 504,
message: "Upstream timed out",
});
expect(result.status).toBe(504);
expect(result.headers.get("Content-Type")).toBe("application/json");
const body = await result.json();
expect(body.error).toBe(true);
expect(body.code).toBe("TIMEOUT");
expect(body.message).toBe("Upstream timed out");
});
test("should include CORS headers in error response", async () => {
const result = createErrorResponse({
code: "SSRF_BLOCKED",
status: 403,
message: "Blocked",
});
expect(result.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
test("should handle different error types", async () => {
const result = createErrorResponse({
code: "DNS_FAILURE",
status: 502,
message: "DNS resolution failed",
});
expect(result.status).toBe(502);
const body = await result.json();
expect(body.code).toBe("DNS_FAILURE");
});
});
// ─── CreateCorsPreflightResponse ───────────────────────────────────────────
describe("createCorsPreflightResponse", () => {
test("should return 204 with CORS headers", () => {
const result = createCorsPreflightResponse();
expect(result.status).toBe(204);
expect(result.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(result.headers.get("Access-Control-Allow-Methods")).toBe(
"GET, POST, PUT, DELETE, PATCH, OPTIONS",
);
expect(result.headers.get("Access-Control-Allow-Headers")).toBe("*");
expect(result.headers.get("Access-Control-Max-Age")).toBe("86400");
});
test("should have no body", async () => {
const result = createCorsPreflightResponse();
const text = await result.text();
expect(text).toBe("");
});
});
// ─── CreateRelayResponse ───────────────────────────────────────────────────
describe("createRelayResponse", () => {
test("should preserve status", async () => {
const mockResponse = new Response("body", { status: 418 });
const result = createRelayResponse(mockResponse);
expect(result.status).toBe(418);
});
test("should preserve allowed headers", async () => {
const mockResponse = new Response("body", {
headers: { "x-custom": "value" },
});
const result = createRelayResponse(mockResponse);
expect(result.headers.get("x-custom")).toBe("value");
});
test("should preserve body", async () => {
const mockResponse = new Response("test body content");
const result = createRelayResponse(mockResponse);
expect(await result.text()).toBe("test body content");
});
test("should handle different status codes", async () => {
const mockResponse = new Response(null, { status: 404 });
const result = createRelayResponse(mockResponse);
expect(result.status).toBe(404);
});
test("should handle 500 status", async () => {
const mockResponse = new Response("error", { status: 500 });
const result = createRelayResponse(mockResponse);
expect(result.status).toBe(500);
});
test("should add CORS headers to relayed response", () => {
const mockResponse = new Response("ok");
const result = createRelayResponse(mockResponse);
expect(result.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
test("should strip blocked response headers", () => {
const mockResponse = new Response("ok", {
headers: {
"set-cookie": "session=secret",
"transfer-encoding": "chunked",
},
});
const result = createRelayResponse(mockResponse);
expect(result.headers.has("set-cookie")).toBe(false);
expect(result.headers.has("transfer-encoding")).toBe(false);
});
});
// ─── Integration: Full relay flow with mocked components ───────────────────
describe("integration: full relay flow", () => {
test("complete flow with target URL construction", () => {
const target = "https://httpbin.org";
const relayPath = "/get";
const targetUrl = normalizeTargetUrl(target, relayPath);
expect(targetUrl).toBeInstanceOf(URL);
expect(targetUrl!.href).toBe("https://httpbin.org/get");
});
test("SSRF protection blocks private targets", () => {
const targetUrl = normalizeTargetUrl("http://localhost:8080", "/admin");
expect(targetUrl).toBeInstanceOf(URL);
expect(isAllowedTarget(targetUrl!)).toBe(false);
});
test("request header filtering strips sensitive headers", () => {
const originalHeaders = new Headers({
"x-relay-target": "https://example.com",
"x-relay-path": "/api",
host: "localhost",
"x-custom": "preserved",
});
const filteredHeaders = filterRequestHeaders(originalHeaders);
expect(filteredHeaders.get("x-relay-target")).toBeNull();
expect(filteredHeaders.get("x-relay-path")).toBeNull();
expect(filteredHeaders.get("host")).toBeNull();
expect(filteredHeaders.get("x-custom")).toBe("preserved");
});
test("buildRelayRequest produces correct options for POST", () => {
const req = new Request("http://test.com", {
method: "POST",
body: JSON.stringify({ data: "test" }),
headers: { "content-type": "application/json" },
});
const options = buildRelayRequest(req, new Headers());
expect(options.method).toBe("POST");
expect(options.body).toBeDefined();
});
test("error case: missing target returns null", () => {
const result = normalizeTargetUrl(null, "/test");
expect(result).toBeNull();
});
test("error case: classifyFetchError and createErrorResponse work together", async () => {
const error = new DOMException("timeout", "TimeoutError");
const classified = classifyFetchError(error);
const response = createErrorResponse(classified);
expect(response.status).toBe(504);
const body = await response.json();
expect(body.code).toBe("TIMEOUT");
expect(body.error).toBe(true);
}); });
}); });
+493 -37
View File
@@ -1,52 +1,508 @@
const BLOCKED_HEADERS = new Set([ /**
"host", * Relay utilities for the edge proxy.
"x-relay-target", * Handles URL normalization, header filtering, SSRF protection,
"x-relay-path", * request building, and error handling.
*/
// ─── Types & Classes ───────────────────────────────────────────────────────────
export class RelayError extends Error {
public readonly name = 'RelayError' as const;
constructor(
public readonly code:
| 'TIMEOUT'
| 'DNS_FAILURE'
| 'CONNECTION_REFUSED'
| 'NETWORK_ERROR'
| 'INVALID_TARGET'
| 'SSRF_BLOCKED'
| 'BODY_TOO_LARGE'
| 'UPSTREAM_ERROR',
public readonly status: number,
message: string,
) {
super(message);
}
}
// ─── URL Handling ──────────────────────────────────────────────────────────────
/**
* Combines a relay target URL with a path, returning a URL object.
*
* - If `target` is null, empty, or whitespace-only, returns null.
* - Merges query parameters from both `target` and `relayPath`.
* - Returns a `URL` object (call `.toString()` or `.href` for a string).
*/
export function normalizeTargetUrl(
target: string | null,
relayPath: string,
): URL | null {
if (!target || target.trim().length === 0) return null;
const normalizedTarget = target.replace(/\/+$/, '');
const cleanRelayPath = relayPath.startsWith('/')
? relayPath
: '/' + relayPath;
try {
const baseUrl = new URL(normalizedTarget);
const baseOrigin = baseUrl.origin;
const basePathname = baseUrl.pathname;
// Strip query string from relayPath before concatenating
const relayPathOnly = cleanRelayPath.includes('?')
? cleanRelayPath.slice(0, cleanRelayPath.indexOf('?'))
: cleanRelayPath;
const combinedPath =
basePathname === '/'
? relayPathOnly
: basePathname.replace(/\/$/, '') + relayPathOnly;
const combined = new URL(combinedPath, baseOrigin);
// Preserve query parameters from the target URL
const targetParams = Array.from(baseUrl.searchParams);
for (const [key, value] of targetParams) {
combined.searchParams.set(key, value);
}
// Merge query parameters from relayPath
if (cleanRelayPath.includes('?')) {
const relayQueryStr = cleanRelayPath.slice(
cleanRelayPath.indexOf('?') + 1,
);
if (relayQueryStr.length > 0) {
const relayParams = Array.from(new URLSearchParams(relayQueryStr));
for (const [key, value] of relayParams) {
combined.searchParams.append(key, value);
}
}
}
return combined;
} catch {
return null;
}
}
// ─── SSRF Protection ───────────────────────────────────────────────────────────
/** Regex patterns for private / loopback / link-local IP ranges. */
const PRIVATE_IP_PATTERNS: RegExp[] = [
// IPv4
/^127\./, // loopback
/^10\./, // private class A
/^172\.(?:1[6-9]|2\d|3[01])\./, // private class B
/^192\.168\./, // private class C
/^169\.254\./, // link-local
/^0\./, // current network
/^0\.0\.0\.0$/, // unspecified
// IPv6
/^::$/, // unspecified
/^::1$/, // loopback
/^fe80:/i, // link-local
/^fd00:/i, // unique local
/^fc00:/i, // unique local
];
const PRIVATE_HOSTNAMES = new Set([
'localhost',
'localhost.localdomain',
'localhost6',
'localhost6.localdomain6',
'metadata.google.internal',
'metadata.internal',
'169.254.169.254',
]); ]);
export function normalizeTargetUrl(target: string | null, relayPath: string): string | null { const PRIVATE_HOSTNAME_SUFFIXES = ['.local', '.internal'];
if (!target) return null;
return target.replace(/\/$/, "") + relayPath; /**
* Returns `true` when `hostname` is a private / loopback / link-local IP
* or a well-known private hostname string.
*/
export function isPrivateIp(hostname: string): boolean {
const lower = hostname.toLowerCase();
// Check known private hostnames
if (PRIVATE_HOSTNAMES.has(lower)) return true;
// Check hostname suffixes (e.g. *.local, *.internal)
for (const suffix of PRIVATE_HOSTNAME_SUFFIXES) {
if (lower.endsWith(suffix)) return true;
}
// Check IP patterns
for (const pattern of PRIVATE_IP_PATTERNS) {
if (pattern.test(lower)) return true;
}
return false;
} }
export function filterHeaders(headers: Headers): Headers { /**
const filtered = new Headers(headers); * Validates a parsed URL is allowed for proxying.
for (const key of BLOCKED_HEADERS) { *
filtered.delete(key); * - Only `http:` and `https:` protocols are permitted.
} * - Hostname must not resolve to a private / internal IP (SSRF protection).
return filtered; */
export function isAllowedTarget(url: URL): boolean {
// Protocol check
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false;
}
// SSRF check — block private / internal hosts
if (isPrivateIp(url.hostname)) {
return false;
}
return true;
} }
// ─── Header Filtering ──────────────────────────────────────────────────────────
/**
* Set of exact header names (lower-case) to strip from **outgoing** relay
* requests.
*/
export const BLOCKED_REQUEST_HEADERS = new Set([
// Relay control headers
'host',
'x-relay-target',
'x-relay-path',
// Hop-by-hop headers (should never be forwarded)
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailers',
'transfer-encoding',
'upgrade',
// Security-sensitive — strip by default
'cookie',
'set-cookie',
// Vercel platform headers
'x-vercel-id',
'x-vercel-deployment-url',
'x-vercel-oidc-token',
'x-vercel-signature',
'x-vercel-edgified',
'x-vercel-proxy-signature',
'x-vercel-ip-city',
'x-vercel-ip-country',
'x-vercel-ip-country-region',
'x-vercel-ip-latency',
'x-vercel-ip-longitude',
'x-vercel-ip-timezone',
'x-vercel-forwarded-for',
'x-vercel-set-bucket',
// Cloudflare platform headers
'cf-ray',
'cf-connecting-ip',
'cf-ipcountry',
'cf-visitor',
'cf-worker',
'cf-edge',
// Forwarded-for metadata (privacy)
'x-forwarded-for',
'x-forwarded-host',
'x-forwarded-proto',
'x-real-ip',
'forwarded',
'via',
]);
/**
* Header name prefixes that cause a header to be stripped from **outgoing**
* relay requests. Matching is case-insensitive.
*/
export const BLOCKED_REQUEST_PREFIXES = [
'x-vercel-',
'cf-',
'x-forwarded-',
'x-envoy-',
];
// Pre-computed lower-case versions for efficient matching
const BLOCKED_REQUEST_PREFIXES_LOWER = BLOCKED_REQUEST_PREFIXES.map((p) =>
p.toLowerCase(),
);
/**
* Strips sensitive / unnecessary headers from an outgoing relay request.
*
* Removes:
* 1. Exact matches against `BLOCKED_REQUEST_HEADERS` (case-insensitive).
* 2. Any header whose lower-case key starts with an entry in
* `BLOCKED_REQUEST_PREFIXES`.
*
* Returns a **new** `Headers` instance the original is not mutated.
*/
export function filterRequestHeaders(headers: Headers): Headers {
const filtered = new Headers();
const headerEntries = Array.from(headers);
for (const [key, value] of headerEntries) {
const lower = key.toLowerCase();
// Check exact blocked headers
if (BLOCKED_REQUEST_HEADERS.has(lower)) continue;
// Check blocked prefixes
let blockedByPrefix = false;
for (const prefix of BLOCKED_REQUEST_PREFIXES_LOWER) {
if (lower.startsWith(prefix)) {
blockedByPrefix = true;
break;
}
}
if (blockedByPrefix) continue;
filtered.set(key, value);
}
return filtered;
}
/**
* Headers to strip from **incoming** relay responses before sending back to
* the caller.
*/
export const BLOCKED_RESPONSE_HEADERS = new Set([
'set-cookie',
'transfer-encoding',
'keep-alive',
'connection',
]);
const CORS_HEADERS: Record<string, string> = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': '*',
};
/**
* Strips sensitive headers from a relay **response** and attaches standard
* CORS headers.
*
* Returns a **new** `Headers` instance the original is not mutated.
*/
export function filterResponseHeaders(headers: Headers): Headers {
const filtered = new Headers(headers);
const blockedKeys = Array.from(BLOCKED_RESPONSE_HEADERS);
for (const key of blockedKeys) {
filtered.delete(key);
}
for (const [key, value] of Object.entries(CORS_HEADERS)) {
filtered.set(key, value);
}
return filtered;
}
// ─── Backward Compatibility ────────────────────────────────────────────────────
/**
* @deprecated Use `filterRequestHeaders` instead. Kept for compatibility
* with existing callers (`route.ts`, tests).
*/
export const filterHeaders = filterRequestHeaders;
// ─── Request Building ──────────────────────────────────────────────────────────
/**
* Returns `true` when the HTTP method typically carries a request body.
*
* `GET`, `HEAD`, and `CONNECT` are the only common methods that never carry
* a body. Everything else (POST, PUT, PATCH, DELETE, OPTIONS, etc.) may.
*/
export function shouldSendBody(method: string): boolean { export function shouldSendBody(method: string): boolean {
return method !== "GET" && method !== "HEAD"; const upper = method.toUpperCase();
return upper !== 'GET' && upper !== 'HEAD' && upper !== 'CONNECT';
} }
export function buildRelayRequest(req: Request, headers: Headers): RequestInit { /**
return { * Constructs a `RequestInit` suitable for passing to `fetch()`.
method: req.method, *
headers, * - Applies the (already-filtered) headers.
body: shouldSendBody(req.method) ? req.body : undefined, * - Attaches a `ReadableStream` body when the method permits it (with
duplex: "half", * `duplex: 'half'` as required by the spec for streaming bodies).
} as any; * - Attaches an `AbortSignal.timeout()` signal.
*/
export function buildRelayRequest(
req: Request,
headers: Headers,
timeoutMs?: number,
): RequestInit {
const timeout = timeoutMs ?? 30_000;
const method = req.method;
const body = shouldSendBody(method) ? req.body : undefined;
const init: RequestInit & { duplex?: 'half' } = {
method,
headers,
signal: AbortSignal.timeout(timeout),
};
if (body) {
init.body = body;
init.duplex = 'half';
}
return init;
} }
export function isAllowedTarget(url: string): boolean { // ─── Response Building ─────────────────────────────────────────────────────────
try {
const parsed = new URL(url);
return ["http:", "https:"].includes(parsed.protocol);
} catch {
return false;
}
}
/**
* Creates a relay-friendly `Response` by passing through the upstream status,
* status text, and body while sanitising headers via `filterResponseHeaders`.
*/
export function createRelayResponse(response: Response): Response { export function createRelayResponse(response: Response): Response {
const headers = new Headers(response.headers); const headers = filterResponseHeaders(response.headers);
headers.set("Access-Control-Allow-Origin", "*");
headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
headers.set("Access-Control-Allow-Headers", "*");
return new Response(response.body, { return new Response(response.body, {
status: response.status, status: response.status,
headers, statusText: response.statusText,
}); headers,
});
}
// ─── Error Handling ────────────────────────────────────────────────────────────
interface ErrorClassification {
code: string;
status: number;
message: string;
}
/**
* Classifies a caught `unknown` into a structured error with an HTTP status
* code and a user-facing message suitable for JSON error responses.
*/
export function classifyFetchError(error: unknown): ErrorClassification {
// RelayError passes through its own classification
if (error instanceof RelayError) {
return {
code: error.code,
status: error.status,
message: error.message,
};
}
// AbortError from AbortSignal.timeout or controller.abort()
if (
error instanceof DOMException &&
(error.name === 'AbortError' || error.name === 'TimeoutError')
) {
return {
code: 'TIMEOUT',
status: 504,
message: 'Upstream timed out',
};
}
if (error instanceof TypeError) {
const msg = error.message.toLowerCase();
if (
msg.includes('dns') ||
msg.includes('resolve') ||
msg.includes('hostname') ||
msg.includes('enotfound')
) {
return {
code: 'DNS_FAILURE',
status: 502,
message: 'DNS resolution failed',
};
}
if (
msg.includes('refused') ||
msg.includes('econnrefused') ||
msg.includes('connection refused')
) {
return {
code: 'CONNECTION_REFUSED',
status: 502,
message: 'Connection refused',
};
}
if (
msg.includes('fetch failed') ||
msg.includes('network') ||
msg.includes('econnreset') ||
msg.includes('econnaborted') ||
msg.includes('enetunreach')
) {
return {
code: 'NETWORK_ERROR',
status: 502,
message: 'Network error',
};
}
// Generic TypeError that doesn't match known patterns
return {
code: 'NETWORK_ERROR',
status: 502,
message: 'Network error',
};
}
// Fallback
return {
code: 'NETWORK_ERROR',
status: 502,
message: 'Unknown upstream error',
};
}
/**
* Produces a JSON `Response` from a structured error classification.
*
* Body includes `error`, `code`, and `message` fields. CORS headers are
* attached so the caller can read the error from a browser.
*/
export function createErrorResponse(error: ErrorClassification): Response {
const body = JSON.stringify({
error: true,
code: error.code,
message: error.message,
});
return new Response(body, {
status: error.status,
headers: {
'Content-Type': 'application/json',
...CORS_HEADERS,
},
});
}
// ─── CORS Preflight ────────────────────────────────────────────────────────────
/**
* Returns a 204 No Content response with CORS preflight headers.
*
* This is a convenience so the relay server does not need to manually
* construct CORS OPTIONS responses.
*/
export function createCorsPreflightResponse(): Response {
return new Response(null, {
status: 204,
headers: {
...CORS_HEADERS,
'Access-Control-Max-Age': '86400',
},
});
} }
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Body limiter test suite.
*
* Covers default size limit, custom configuration,
* Content-Length checks, and edge cases.
*/
import { test, expect, describe, beforeEach } from "bun:test";
import {
checkBodySize,
getMaxBodySize,
setMaxBodySize,
} from "./body-limiter";
describe("body-limiter", () => {
beforeEach(() => {
// Reset to default before each test
setMaxBodySize(1_048_576); // 1 MB
});
describe("getMaxBodySize / setMaxBodySize", () => {
test("should default to 1MB", () => {
expect(getMaxBodySize()).toBe(1_048_576);
});
test("should allow setting a custom max size", () => {
setMaxBodySize(512);
expect(getMaxBodySize()).toBe(512);
});
test("should allow setting zero", () => {
setMaxBodySize(0);
expect(getMaxBodySize()).toBe(0);
});
test("should throw for negative values", () => {
expect(() => setMaxBodySize(-1)).toThrow(
"maxBodySize must be a non-negative number",
);
});
test("should allow setting a very large size", () => {
setMaxBodySize(100_000_000);
expect(getMaxBodySize()).toBe(100_000_000);
});
});
describe("checkBodySize", () => {
test("should return null when Content-Length is under the default limit", () => {
const request = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "500" },
});
expect(checkBodySize(request)).toBeNull();
});
test("should return null when Content-Length is exactly at the default limit", () => {
const request = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "1048576" },
});
expect(checkBodySize(request)).toBeNull();
});
test("should return 413 Response when Content-Length exceeds the default limit", () => {
const request = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "1048577" },
});
const result = checkBodySize(request);
expect(result).toBeInstanceOf(Response);
expect(result!.status).toBe(413);
expect(result!.headers.get("Content-Type")).toBe("application/json");
});
test("413 response should include error details as JSON", async () => {
setMaxBodySize(100);
const request = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "200" },
});
const result = checkBodySize(request);
const body = await result!.json();
expect(body.error).toBe("Payload Too Large");
expect(body.maxSizeBytes).toBe(100);
});
test("should return null when no Content-Length header is present", () => {
const request = new Request("http://localhost/test", {
method: "POST",
});
expect(checkBodySize(request)).toBeNull();
});
test("should return null for malformed Content-Length", () => {
const request = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "not-a-number" },
});
expect(checkBodySize(request)).toBeNull();
});
test("should return null for negative Content-Length", () => {
const request = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "-100" },
});
expect(checkBodySize(request)).toBeNull();
});
test("should use custom max body size when set", () => {
setMaxBodySize(500);
const underLimit = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "499" },
});
expect(checkBodySize(underLimit)).toBeNull();
const overLimit = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "501" },
});
expect(checkBodySize(overLimit)!.status).toBe(413);
});
test("should handle GET requests with Content-Length", () => {
const request = new Request("http://localhost/test", {
method: "GET",
headers: { "Content-Length": "10" },
});
expect(checkBodySize(request)).toBeNull();
});
test("should handle zero Content-Length", () => {
const request = new Request("http://localhost/test", {
method: "POST",
headers: { "Content-Length": "0" },
});
expect(checkBodySize(request)).toBeNull();
});
});
});
+69
View File
@@ -0,0 +1,69 @@
/**
* Request body size limiter.
*
* Checks the Content-Length header against a configurable maximum.
* Returns a 413 Payload Too Large response when the body exceeds the limit.
* Requests without a Content-Length header are passed through since
* the body size cannot be determined upfront with streaming.
*/
const DEFAULT_MAX_BODY_SIZE = 1_048_576; // 1 MB
let maxBodySize = DEFAULT_MAX_BODY_SIZE;
/**
* Check whether the request body exceeds the configured size limit.
*
* Returns a 413 Response if the Content-Length header indicates the body
* is too large. Returns `null` if the body is acceptable or if the size
* cannot be determined (no Content-Length header).
*/
export function checkBodySize(request: Request): Response | null {
const contentType = request.headers.get("content-length");
if (contentType === null) {
// Cannot determine size upfront — pass through (streaming body).
return null;
}
const contentLength = Number.parseInt(contentType, 10);
if (Number.isNaN(contentLength) || contentLength < 0) {
// Malformed Content-Length — pass through and let the server handle it.
return null;
}
if (contentLength > maxBodySize) {
return new Response(
JSON.stringify({
error: "Payload Too Large",
message: `Request body exceeds the maximum allowed size of ${maxBodySize} bytes`,
maxSizeBytes: maxBodySize,
}),
{
status: 413,
headers: {
"Content-Type": "application/json",
},
},
);
}
return null;
}
/**
* Update the maximum allowed body size.
*/
export function setMaxBodySize(bytes: number): void {
if (bytes < 0) {
throw new Error("maxBodySize must be a non-negative number");
}
maxBodySize = bytes;
}
/**
* Get the current maximum allowed body size in bytes.
*/
export function getMaxBodySize(): number {
return maxBodySize;
}
+5
View File
@@ -0,0 +1,5 @@
export { checkBodySize, getMaxBodySize, setMaxBodySize } from "./body-limiter";
export type { RelayLogEvent } from "./logger";
export { createRequestLogger, logRelayEvent } from "./logger";
export type { RateLimiter, RateLimiterOptions } from "./rate-limiter";
export { createRateLimiter } from "./rate-limiter";
+102
View File
@@ -0,0 +1,102 @@
/**
* Logger test suite.
*
* Covers structured logging, TTY vs JSON output,
* and the request logger factory.
*/
import { test, expect, describe, spyOn } from "bun:test";
import { logRelayEvent, createRequestLogger } from "./logger";
describe("logger", () => {
describe("logRelayEvent", () => {
test("should log all required fields without error", () => {
const spy = spyOn(console, "log");
spy.mockImplementation(() => {});
logRelayEvent({
method: "GET",
url: "/health",
status: 200,
durationMs: 15,
});
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
test("should log event with error field", () => {
const spy = spyOn(console, "log");
spy.mockImplementation(() => {});
logRelayEvent({
method: "POST",
url: "/relay",
status: 502,
durationMs: 5000,
error: "DNS resolution failed",
});
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
test("should log event with all optional fields", () => {
const spy = spyOn(console, "log");
spy.mockImplementation(() => {});
logRelayEvent({
method: "GET",
url: "/test",
status: 200,
durationMs: 42,
targetUrl: "https://example.com/api",
ip: "203.0.113.1",
});
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
test("should not throw for any valid event shape", () => {
expect(() =>
logRelayEvent({
method: "OPTIONS",
url: "/cors-test",
status: 204,
durationMs: 0,
}),
).not.toThrow();
});
});
describe("createRequestLogger", () => {
test("should return a function", () => {
const logger = createRequestLogger();
expect(typeof logger).toBe("function");
});
test("returned function should log without error", () => {
const logger = createRequestLogger();
const req = new Request("http://localhost/test", {
method: "POST",
});
const res = new Response("ok", { status: 200 });
expect(() => logger(req, res, performance.now())).not.toThrow();
});
test("returned function should accept extra fields", () => {
const logger = createRequestLogger();
const req = new Request("http://localhost/relay");
const res = new Response("relayed", { status: 200 });
expect(() =>
logger(req, res, performance.now(), {
targetUrl: "https://example.com",
ip: "10.0.0.1",
}),
).not.toThrow();
});
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* Structured logger for relay proxy events.
*
* Logs events as JSON lines for machine parsing and provides
* optional TTY colorization for local development.
*/
export interface RelayLogEvent {
method: string;
url: string;
status: number;
durationMs: number;
error?: string;
targetUrl?: string;
ip?: string;
}
type LogLevel = "info" | "warn" | "error";
/** Determine log level from HTTP status code. */
function levelFromStatus(status: number): LogLevel {
if (status >= 500) return "error";
if (status >= 400) return "warn";
return "info";
}
/** ANSI color codes for TTY output. */
const TTY_COLORS: Record<LogLevel, string> = {
info: "", // green
warn: "", // yellow
error: "", // red
};
const TTY_RESET = "";
const TTY_DIM = "";
/** Check if stdout is a TTY (for colorization). */
function isTTY(): boolean {
return process.stdout?.isTTY === true;
}
/**
* Log a structured relay event to stdout.
*
* When writing to a TTY the output includes ANSI colors for readability.
* When writing to a pipe/file it produces clean JSON lines.
*/
export function logRelayEvent(event: RelayLogEvent): void {
const { method, url, status, durationMs, error, targetUrl, ip } = event;
const level = levelFromStatus(status);
const timestamp = new Date().toISOString();
const durationFormatted = `${durationMs}ms`;
const tty = isTTY();
const color = tty ? (TTY_COLORS[level] ?? "") : "";
const reset = tty ? TTY_RESET : "";
const dim = tty ? TTY_DIM : "";
if (tty) {
const statusColor =
status >= 500 ? TTY_COLORS.error : status >= 400 ? TTY_COLORS.warn : "";
const parts: string[] = [
`${dim}${timestamp}${reset}`,
`${color}[${level.toUpperCase()}]${reset}`,
`${method}`,
`${statusColor}${status}${reset}`,
`${dim}${durationFormatted}${reset}`,
url,
];
if (targetUrl) parts.push(`${dim}-> ${targetUrl}${reset}`);
if (ip) parts.push(`${dim}(${ip})${reset}`);
if (error) parts.push(`${color}${error}${reset}`);
console.log(parts.join(" "));
} else {
const logEntry: Record<string, unknown> = {
timestamp,
level,
method,
url,
status,
durationMs: durationFormatted,
};
if (error) logEntry.error = error;
if (targetUrl) logEntry.targetUrl = targetUrl;
if (ip) logEntry.ip = ip;
console.log(JSON.stringify(logEntry));
}
}
/**
* Create a middleware-compatible request logger.
*
* Example usage in a Bun.serve() handler:
*
* const requestLogger = createRequestLogger();
* const start = performance.now();
* // ... handle request ...
* requestLogger(req, res, start, { targetUrl });
*/
export function createRequestLogger(): (
req: Request,
res: Response,
startTime: number,
extra?: Partial<RelayLogEvent>,
) => void {
return (req, res, startTime, extra) => {
const durationMs = Math.round(performance.now() - startTime);
logRelayEvent({
method: req.method,
url: req.url,
status: res.status,
durationMs,
error: extra?.error,
targetUrl: extra?.targetUrl,
ip: extra?.ip,
});
};
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Rate limiter test suite.
*
* Covers default and custom options, window enforcement,
* reset semantics, and edge cases.
*/
import { test, expect, describe } from "bun:test";
import { createRateLimiter } from "./rate-limiter";
describe("rate-limiter", () => {
describe("createRateLimiter", () => {
test("should allow requests up to the default limit", () => {
const limiter = createRateLimiter({ maxRequests: 5, windowMs: 60_000 });
for (let i = 0; i < 5; i++) {
const result = limiter.check("test-key");
expect(result.allowed).toBe(true);
}
});
test("should block requests exceeding the limit", () => {
const limiter = createRateLimiter({ maxRequests: 3, windowMs: 60_000 });
for (let i = 0; i < 3; i++) {
expect(limiter.check("block-key").allowed).toBe(true);
}
const blocked = limiter.check("block-key");
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfterMs).toBeDefined();
expect(typeof blocked.retryAfterMs).toBe("number");
});
test("should return retryAfterMs when blocked", () => {
const limiter = createRateLimiter({
maxRequests: 1,
windowMs: 60_000,
});
limiter.check("retry-key");
const blocked = limiter.check("retry-key");
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfterMs).toBeGreaterThan(0);
expect(blocked.retryAfterMs).toBeLessThanOrEqual(60_000);
});
test("reset() should clear the counter", () => {
const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 });
limiter.check("reset-key");
limiter.check("reset-key");
// would be blocked, but...
limiter.reset("reset-key");
// ...should be allowed again
expect(limiter.check("reset-key").allowed).toBe(true);
});
test("should isolate keys from each other", () => {
const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 });
expect(limiter.check("key-a").allowed).toBe(true);
expect(limiter.check("key-a").allowed).toBe(true);
expect(limiter.check("key-b").allowed).toBe(true); // key-b unaffected
expect(limiter.check("key-a").allowed).toBe(false); // key-a blocked
});
test("should create with default options", () => {
const limiter = createRateLimiter();
expect(limiter.check).toBeDefined();
expect(limiter.reset).toBeDefined();
});
test("should handle rapid sequential calls", () => {
const limiter = createRateLimiter({ maxRequests: 100, windowMs: 60_000 });
for (let i = 0; i < 100; i++) {
expect(limiter.check("rapid-key").allowed).toBe(true);
}
expect(limiter.check("rapid-key").allowed).toBe(false);
});
test("should allow requests after reset", () => {
const limiter = createRateLimiter({ maxRequests: 1, windowMs: 60_000 });
limiter.check("after-reset-key");
const blocked = limiter.check("after-reset-key");
expect(blocked.allowed).toBe(false);
limiter.reset("after-reset-key");
expect(limiter.check("after-reset-key").allowed).toBe(true);
});
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* In-memory sliding window rate limiter for Bun relay proxy.
*
* Uses a Map<string, number[]> where each key maps to an array of
* Unix-epoch millisecond timestamps. Older entries are purged on
* every check() call and periodically via a background interval.
*
* Bun is single-threaded so no locking is required.
*/
export interface RateLimiterOptions {
maxRequests?: number;
windowMs?: number;
}
export interface RateLimiter {
check(key: string): { allowed: boolean; retryAfterMs?: number };
reset(key: string): void;
}
const DEFAULT_MAX_REQUESTS = 100;
const DEFAULT_WINDOW_MS = 60_000; // 1 minute
const CLEANUP_INTERVAL_DIVISOR = 10;
export function createRateLimiter(options?: RateLimiterOptions): RateLimiter {
const maxRequests = options?.maxRequests ?? DEFAULT_MAX_REQUESTS;
const windowMs = options?.windowMs ?? DEFAULT_WINDOW_MS;
// Map of key -> sorted array of timestamps (ascending)
const store = new Map<string, number[]>();
// ── helpers ──────────────────────────────────────────────────────
/** Remove timestamps outside the sliding window. Returns the pruned slice. */
function prune(key: string, now: number): number[] {
const timestamps = store.get(key);
if (!timestamps) return [];
const cutoff = now - windowMs;
const result: number[] = [];
for (let i = 0; i < timestamps.length; i++) {
if (timestamps[i] >= cutoff) {
result.push(timestamps[i]);
}
}
if (result.length === 0) {
store.delete(key);
} else {
store.set(key, result);
}
return result;
}
/** Periodically sweep the entire store to free memory. */
function periodicCleanup(): void {
const now = Date.now();
const cutoff = now - windowMs;
for (const [key, timestamps] of store) {
const pruned: number[] = [];
for (let i = 0; i < timestamps.length; i++) {
if (timestamps[i] >= cutoff) {
pruned.push(timestamps[i]);
}
}
if (pruned.length === 0) {
store.delete(key);
} else {
store.set(key, pruned);
}
}
}
// Schedule periodic cleanup (every windowMs / 10)
const cleanupHandle = setInterval(
periodicCleanup,
windowMs / CLEANUP_INTERVAL_DIVISOR,
);
// Allow the process to exit even if the interval is still active
if (
cleanupHandle &&
typeof cleanupHandle === "object" &&
"unref" in cleanupHandle
) {
(cleanupHandle as NodeJS.Timeout).unref();
}
// ── public API ───────────────────────────────────────────────────
return {
check(key: string): { allowed: boolean; retryAfterMs?: number } {
const now = Date.now();
const timestamps = prune(key, now);
timestamps.push(now);
store.set(key, timestamps);
if (timestamps.length <= maxRequests) {
return { allowed: true };
}
// Not allowed — calculate retry-after from the oldest timestamp
const oldest = timestamps[0];
const retryAfterMs = oldest + windowMs - now;
console.warn(
`[rate-limiter] Rate limit exceeded for key="${key}": ${timestamps.length} requests in ${windowMs}ms (max ${maxRequests})`,
);
return { allowed: false, retryAfterMs };
},
reset(key: string): void {
store.delete(key);
},
};
}
+2 -11
View File
@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -11,24 +11,15 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx",
"types": ["bun-types"], "types": ["bun-types"],
"incremental": true, "incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": [ "include": [
"next-env.d.ts",
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts" "**/*.mts"
], ],
"exclude": ["node_modules"] "exclude": ["node_modules"]
+7 -3
View File
@@ -1,5 +1,9 @@
{ {
"$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "bun install",
"bunVersion": "1", "outputDirectory": "dist",
"installCommand": "bun install" "functions": {
"src/index.ts": {
"runtime": "bun@1"
}
}
} }
+8 -6
View File
@@ -1,8 +1,10 @@
name = "opennext-app" name = "edge-proxy-relay"
main = ".open-next/worker.js" main = "src/index.ts"
compatibility_date = "2024-09-23" compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"] compatibility_flags = ["nodejs_compat"]
[assets] [vars]
directory = ".open-next/assets" NODE_VERSION = "22"
binding = "ASSETS"
[env.production]
name = "edge-proxy-relay-prod"