From f58a92e2aa962c4aa716692fe598aec78434a4c2 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 10 Jun 2026 19:54:55 +0700 Subject: [PATCH] feat: migrate from Next.js to pure Bun relay proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .github/workflows/deploy.yml | 27 +- .gitignore | 5 + CLAUDE.md | 10 + README.md | 335 ++++++++--- biome.json | 34 -- components.json | 25 - eslint.config.mjs | 18 - index.test.ts | 251 -------- next.config.ts | 7 - open-next.config.ts | 3 - package.json | 45 +- postcss.config.mjs | 7 - src/.gitignore | 4 + src/app/docs/page.tsx | 219 ------- src/app/favicon.ico | Bin 25931 -> 0 bytes src/app/globals.css | 130 ---- src/app/layout.tsx | 23 - src/app/route.ts | 60 -- src/components/ui/badge.tsx | 52 -- src/components/ui/button.tsx | 58 -- src/components/ui/card.tsx | 103 ---- src/components/ui/input.tsx | 20 - src/components/ui/label.tsx | 20 - src/components/ui/select.tsx | 201 ------- src/components/ui/separator.tsx | 25 - src/components/ui/sonner.tsx | 49 -- src/components/ui/tabs.tsx | 82 --- src/components/ui/textarea.tsx | 18 - src/index.test.ts | 137 +++++ src/index.ts | 558 +++++++++++++++++ src/lib/relay-utils.test.ts | 901 ++++++++++++++++++++++++++-- src/lib/relay-utils.ts | 530 ++++++++++++++-- src/lib/utils.ts | 6 - src/middleware/body-limiter.test.ts | 142 +++++ src/middleware/body-limiter.ts | 69 +++ src/middleware/index.ts | 5 + src/middleware/logger.test.ts | 102 ++++ src/middleware/logger.ts | 120 ++++ src/middleware/rate-limiter.test.ts | 85 +++ src/middleware/rate-limiter.ts | 117 ++++ tsconfig.json | 13 +- vercel.json | 10 +- wrangler.toml | 14 +- 43 files changed, 2981 insertions(+), 1659 deletions(-) delete mode 100644 biome.json delete mode 100644 components.json delete mode 100644 eslint.config.mjs delete mode 100644 index.test.ts delete mode 100644 next.config.ts delete mode 100644 open-next.config.ts delete mode 100644 postcss.config.mjs create mode 100644 src/.gitignore delete mode 100644 src/app/docs/page.tsx delete mode 100644 src/app/favicon.ico delete mode 100644 src/app/globals.css delete mode 100644 src/app/layout.tsx delete mode 100644 src/app/route.ts delete mode 100644 src/components/ui/badge.tsx delete mode 100644 src/components/ui/button.tsx delete mode 100644 src/components/ui/card.tsx delete mode 100644 src/components/ui/input.tsx delete mode 100644 src/components/ui/label.tsx delete mode 100644 src/components/ui/select.tsx delete mode 100644 src/components/ui/separator.tsx delete mode 100644 src/components/ui/sonner.tsx delete mode 100644 src/components/ui/tabs.tsx delete mode 100644 src/components/ui/textarea.tsx create mode 100644 src/index.test.ts create mode 100644 src/index.ts delete mode 100644 src/lib/utils.ts create mode 100644 src/middleware/body-limiter.test.ts create mode 100644 src/middleware/body-limiter.ts create mode 100644 src/middleware/index.ts create mode 100644 src/middleware/logger.test.ts create mode 100644 src/middleware/logger.ts create mode 100644 src/middleware/rate-limiter.test.ts create mode 100644 src/middleware/rate-limiter.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2712918..3506dc1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore index 9ad2300..09319a4 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index 2cafa3c..0e5bbd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 9bae03b..0124af1 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,312 @@ -# πŸš€ Edge Relay (Proxy Bun) +# Edge Proxy Relay -- Pure Bun HTTP + WebSocket Relay -High-performance HTTP Proxy & Relay handler optimized for Vercel Edge Runtime, Cloudflare Workers, and Bun. +[![Bun](https://img.shields.io/badge/Bun-0.8+-000?logo=bun&logoColor=fff)](https://bun.sh) -## 🌐 Live Deployment +A high-performance HTTP and WebSocket relay proxy built entirely with Bun's standard library. Zero framework dependencies. No Next.js, no Express, no React. -| Provider | Endpoint | -|----------|----------| -| **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 diff --git a/biome.json b/biome.json deleted file mode 100644 index 165f14f..0000000 --- a/biome.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/components.json b/components.json deleted file mode 100644 index 8d886db..0000000 --- a/components.json +++ /dev/null @@ -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": {} -} diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 05e726d..0000000 --- a/eslint.config.mjs +++ /dev/null @@ -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; diff --git a/index.test.ts b/index.test.ts deleted file mode 100644 index 54a8c02..0000000 --- a/index.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/next.config.ts b/next.config.ts deleted file mode 100644 index e9ffa30..0000000 --- a/next.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - /* config options here */ -}; - -export default nextConfig; diff --git a/open-next.config.ts b/open-next.config.ts deleted file mode 100644 index ffd9887..0000000 --- a/open-next.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineCloudflareConfig } from "@opennextjs/cloudflare"; - -export default defineCloudflareConfig(); diff --git a/package.json b/package.json index d68f1db..bdcb152 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/postcss.config.mjs b/postcss.config.mjs deleted file mode 100644 index 61e3684..0000000 --- a/postcss.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -const config = { - plugins: { - "@tailwindcss/postcss": {}, - }, -}; - -export default config; diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..34fee9f --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,4 @@ + +.claude/ + +.codegraph/ diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx deleted file mode 100644 index 7c1cf14..0000000 --- a/src/app/docs/page.tsx +++ /dev/null @@ -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(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 = { - '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 = {}; - 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 ( -
- -
-
-

Edge Proxy

-

High-performance request relay powered by Vercel Edge.

-
- -
- - - Configuration - Set up your target request. - - -
- - setTargetUrl(e.target.value)} - /> -
- -
- - -
- -
-
- - setHeaderKey(e.target.value)} /> -
-
- - setHeaderValue(e.target.value)} /> -
-
- - {!['GET', 'HEAD', 'OPTIONS'].includes(method) && ( -
- -