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:
co-authored by
Claude Fable 5
parent
124dab98a2
commit
f58a92e2aa
@@ -1,32 +1,15 @@
|
||||
name: Deploy to Cloudflare Workers
|
||||
|
||||
name: Deploy
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
branches: [master]
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- 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 }}
|
||||
- run: bun install
|
||||
- run: bun run build
|
||||
- run: bun test
|
||||
|
||||
@@ -13,6 +13,7 @@ coverage
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
*.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
@@ -39,3 +40,7 @@ next-env.d.ts
|
||||
# OpenNext & Wrangler build output
|
||||
.open-next/
|
||||
.wrangler/
|
||||
|
||||
.claude/
|
||||
|
||||
.codegraph/
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
[](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 |
|
||||
|----------|----------|
|
||||
| **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` |
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 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
|
||||
|
||||
| Header | Required | Default | Deskripsi |
|
||||
|--------|----------|---------|-----------|
|
||||
| `x-relay-target` | **Yes** | - | Base URL target (e.g. `https://api.openai.com`) |
|
||||
| `x-relay-path` | No | `/` | Path tambahan (e.g. `/v1/chat/completions`) |
|
||||
| Header | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `x-relay-target` | Yes | Base URL of the upstream target (e.g. `https://api.openai.com`) |
|
||||
| `x-relay-path` | No | Path to append to the target URL (default: `/`) |
|
||||
|
||||
---
|
||||
### Examples
|
||||
|
||||
## 📖 Contoh Penggunaan
|
||||
**Simple GET relay:**
|
||||
|
||||
### 1. Simple GET Request
|
||||
Mengambil data dari JSONPlaceholder.
|
||||
```bash
|
||||
curl -H "x-relay-target: https://jsonplaceholder.typicode.com/posts/1" \
|
||||
https://proxy-bun.vercel.app/
|
||||
http://localhost:3000/
|
||||
```
|
||||
|
||||
### 2. POST with Body & Headers
|
||||
Meneruskan API Key dan data JSON ke target.
|
||||
**POST with body and authorization:**
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "x-relay-target: https://api.example.com" \
|
||||
-H "x-relay-path: /v1/data" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "x-relay-target: https://api.openai.com" \
|
||||
-H "x-relay-path: /v1/chat/completions" \
|
||||
-H "Authorization: Bearer sk-..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key": "value"}' \
|
||||
https://proxy-bun.vercel.app/
|
||||
-d '{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}' \
|
||||
http://localhost:3000/
|
||||
```
|
||||
|
||||
### 3. Binary Data / Upload
|
||||
Mendukung upload file via `POST`/`PUT` (streaming).
|
||||
**Binary upload (streaming):**
|
||||
|
||||
```bash
|
||||
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" \
|
||||
--data-binary "@/path/to/image.png" \
|
||||
https://proxy-bun.vercel.app/
|
||||
http://localhost:3000/
|
||||
```
|
||||
|
||||
---
|
||||
### Supported Methods
|
||||
|
||||
## ⚙️ Fitur & Spesifikasi
|
||||
|
||||
### ⚡ 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. |
|
||||
`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`. Response streaming (Server-Sent Events, large payloads) is supported natively.
|
||||
|
||||
---
|
||||
|
||||
## 📂 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
|
||||
src/
|
||||
├── app/
|
||||
│ ├── route.ts # Entry point proxy (Edge Handler)
|
||||
│ └── docs/ # UI Interactive Docs & Tester
|
||||
└── lib/
|
||||
├── relay-utils.ts # Logic filter header & request builder
|
||||
└── utils.ts # Helper UI
|
||||
├── index.ts # Entry point: Bun.serve() with routing, WS relay,
|
||||
│ # graceful shutdown, middleware orchestration
|
||||
├── lib/
|
||||
│ └── relay-utils.ts # URL normalization, SSRF protection, header
|
||||
│ # filtering, request/response building, error
|
||||
│ # classification, CORS preflight
|
||||
└── middleware/
|
||||
├── index.ts # Barrel exports
|
||||
├── rate-limiter.ts # In-memory sliding window rate limiter (per IP)
|
||||
├── logger.ts # Structured JSON logging with TTY colorization
|
||||
└── body-limiter.ts # Content-Length validation against configurable max
|
||||
```
|
||||
|
||||
## 🏗 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
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Run dev server
|
||||
bun dev
|
||||
# Start dev server with HMR
|
||||
bun run dev
|
||||
|
||||
# Run unit tests
|
||||
# Run tests
|
||||
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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||
|
||||
export default defineCloudflareConfig();
|
||||
+12
-33
@@ -1,41 +1,20 @@
|
||||
{
|
||||
"name": "real-starter",
|
||||
"version": "0.1.0",
|
||||
"name": "edge-proxy-relay",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"build:worker": "bunx opennextjs-cloudflare build",
|
||||
"preview": "wrangler dev",
|
||||
"deploy": "bun run build:worker && wrangler deploy"
|
||||
},
|
||||
"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"
|
||||
"dev": "bun --hot src/index.ts",
|
||||
"start": "bun src/index.ts",
|
||||
"test": "bun test",
|
||||
"test:watch": "bun test --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "bun build src/index.ts --outdir ./dist --target bun",
|
||||
"build:worker": "bun build src/index.ts --outdir ./dist --target bun --format esm"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"bun-types": "^1.3.13",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.5",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"@opennextjs/cloudflare": "latest",
|
||||
"wrangler": "^4.90.0"
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
.claude/
|
||||
|
||||
.codegraph/
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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> · <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
@@ -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 {
|
||||
normalizeTargetUrl,
|
||||
filterHeaders,
|
||||
filterRequestHeaders,
|
||||
filterResponseHeaders,
|
||||
shouldSendBody,
|
||||
isAllowedTarget,
|
||||
isPrivateIp,
|
||||
classifyFetchError,
|
||||
createErrorResponse,
|
||||
createCorsPreflightResponse,
|
||||
buildRelayRequest,
|
||||
createRelayResponse,
|
||||
RelayError,
|
||||
} from "./relay-utils";
|
||||
|
||||
describe("relay-utils", () => {
|
||||
describe("normalizeTargetUrl", () => {
|
||||
test("should combine target and path", () => {
|
||||
expect(normalizeTargetUrl("https://example.com", "/api/v1")).toBe("https://example.com/api/v1");
|
||||
});
|
||||
// ─── NormalizeTargetUrl ────────────────────────────────────────────────────
|
||||
|
||||
test("should handle trailing slash in target", () => {
|
||||
expect(normalizeTargetUrl("https://example.com/", "/api/v1")).toBe("https://example.com/api/v1");
|
||||
});
|
||||
|
||||
test("should return null if target is missing", () => {
|
||||
expect(normalizeTargetUrl(null, "/api/v1")).toBe(null);
|
||||
});
|
||||
describe("normalizeTargetUrl", () => {
|
||||
test("should combine target and path — returns URL object", () => {
|
||||
const result = normalizeTargetUrl("https://example.com", "/api/v1");
|
||||
expect(result).toBeInstanceOf(URL);
|
||||
expect(result!.href).toBe("https://example.com/api/v1");
|
||||
});
|
||||
|
||||
describe("filterHeaders", () => {
|
||||
test("should remove blocked headers", () => {
|
||||
const headers = new Headers({
|
||||
"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);
|
||||
});
|
||||
test("should handle trailing slash in target", () => {
|
||||
const result = normalizeTargetUrl("https://example.com/", "/api/v1");
|
||||
expect(result!.href).toBe("https://example.com/api/v1");
|
||||
});
|
||||
|
||||
describe("shouldSendBody", () => {
|
||||
test("should return false for GET and HEAD", () => {
|
||||
expect(shouldSendBody("GET")).toBe(false);
|
||||
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);
|
||||
});
|
||||
test("should handle trailing slashes in target", () => {
|
||||
const result = normalizeTargetUrl("https://example.com///", "/api/v1");
|
||||
expect(result!.href).toBe("https://example.com/api/v1");
|
||||
});
|
||||
|
||||
describe("isAllowedTarget", () => {
|
||||
test("should allow http and https", () => {
|
||||
expect(isAllowedTarget("https://example.com")).toBe(true);
|
||||
expect(isAllowedTarget("http://example.com")).toBe(true);
|
||||
});
|
||||
test("should return null if target is missing", () => {
|
||||
expect(normalizeTargetUrl(null, "/api/v1")).toBe(null);
|
||||
});
|
||||
|
||||
test("should reject other protocols", () => {
|
||||
expect(isAllowedTarget("ftp://example.com")).toBe(false);
|
||||
expect(isAllowedTarget("javascript:alert(1)")).toBe(false);
|
||||
});
|
||||
test("should return null if target is empty string", () => {
|
||||
expect(normalizeTargetUrl("", "/api/v1")).toBe(null);
|
||||
});
|
||||
|
||||
test("should reject invalid URLs", () => {
|
||||
expect(isAllowedTarget("not-a-url")).toBe(false);
|
||||
test("should return null if target is whitespace-only", () => {
|
||||
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
@@ -1,52 +1,508 @@
|
||||
const BLOCKED_HEADERS = new Set([
|
||||
"host",
|
||||
"x-relay-target",
|
||||
"x-relay-path",
|
||||
/**
|
||||
* Relay utilities for the edge proxy.
|
||||
* Handles URL normalization, header filtering, SSRF protection,
|
||||
* 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 {
|
||||
if (!target) return null;
|
||||
return target.replace(/\/$/, "") + relayPath;
|
||||
const PRIVATE_HOSTNAME_SUFFIXES = ['.local', '.internal'];
|
||||
|
||||
/**
|
||||
* 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);
|
||||
for (const key of BLOCKED_HEADERS) {
|
||||
filtered.delete(key);
|
||||
}
|
||||
return filtered;
|
||||
/**
|
||||
* Validates a parsed URL is allowed for proxying.
|
||||
*
|
||||
* - Only `http:` and `https:` protocols are permitted.
|
||||
* - Hostname must not resolve to a private / internal IP (SSRF protection).
|
||||
*/
|
||||
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 {
|
||||
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 {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: shouldSendBody(req.method) ? req.body : undefined,
|
||||
duplex: "half",
|
||||
} as any;
|
||||
/**
|
||||
* Constructs a `RequestInit` suitable for passing to `fetch()`.
|
||||
*
|
||||
* - Applies the (already-filtered) headers.
|
||||
* - Attaches a `ReadableStream` body when the method permits it (with
|
||||
* `duplex: 'half'` as required by the spec for streaming bodies).
|
||||
* - 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 {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return ["http:", "https:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// ─── Response Building ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const headers = new Headers(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", "*");
|
||||
const headers = filterResponseHeaders(response.headers);
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers,
|
||||
});
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: "[32m", // green
|
||||
warn: "[33m", // yellow
|
||||
error: "[31m", // red
|
||||
};
|
||||
const TTY_RESET = "[0m";
|
||||
const TTY_DIM = "[2m";
|
||||
|
||||
/** 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"target": "ESNext",
|
||||
"lib": ["esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -11,24 +11,15 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["bun-types"],
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
+7
-3
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"bunVersion": "1",
|
||||
"installCommand": "bun install"
|
||||
"buildCommand": "bun install",
|
||||
"outputDirectory": "dist",
|
||||
"functions": {
|
||||
"src/index.ts": {
|
||||
"runtime": "bun@1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-6
@@ -1,8 +1,10 @@
|
||||
name = "opennext-app"
|
||||
main = ".open-next/worker.js"
|
||||
compatibility_date = "2024-09-23"
|
||||
name = "edge-proxy-relay"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2024-12-01"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[assets]
|
||||
directory = ".open-next/assets"
|
||||
binding = "ASSETS"
|
||||
[vars]
|
||||
NODE_VERSION = "22"
|
||||
|
||||
[env.production]
|
||||
name = "edge-proxy-relay-prod"
|
||||
|
||||
Reference in New Issue
Block a user