commit a4fcbbcca92256fb8df5a160f30215a98ec82d81 Author: MythEclipse Date: Thu May 7 18:42:09 2026 +0700 initial commit for proxy-bun diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8deed85 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# Edge Relay + +HTTP proxy untuk Vercel Edge Runtime. + +## Cara Pakai + +### Header yang Dibutuhkan + +| Header | Required | Default | Deskripsi | +|--------|----------|---------|-----------| +| `x-relay-target` | Yes | - | URL target yang ingin di-proxy | +| `x-relay-path` | No | `/` | Path yang ditambahkan ke target | + +### Contoh + +```bash +# Proxy ke httpbin.org/get +curl -H "x-relay-target: https://httpbin.org" https://your-edge-function.vercel.app + +# Proxy ke endpoint spesifik +curl -H "x-relay-target: https://api.example.com" \ + -H "x-relay-path: /v1/users" \ + https://your-edge-function.vercel.app +``` + +### HTTP Methods + +Mendukung semua HTTP methods: +- `GET`, `HEAD` - tanpa body +- `POST`, `PUT`, `PATCH`, `DELETE` - dengan body + +### Header Handling + +Relay headers yang di-strip sebelum forwarded: +- `x-relay-target` +- `x-relay-path` +- `host` + +Headers lain tetap di-pass. + +### Error Handling + +```json +{ + "error": "Missing x-relay-target header" +} +``` +HTTP 400 jika `x-relay-target` tidak ada. + +## Struktur Kode + +``` +proxy-vercel/ +├── index.ts # Edge handler +├── relay-utils.ts # Pure functions untuk relay logic +└── index.test.ts # Unit tests +``` + +### relay-utils.ts + +| Function | Deskripsi | +|----------|-----------| +| `normalizeTargetUrl(target, path)` | Gabung target + path, hapus trailing slash | +| `stripRelayHeaders(headers)` | Hapus relay-specific headers | +| `shouldSendBody(method)` | Cek apakah method butuh body | +| `buildRelayRequest(req, url, headers)` | Bangun RequestInit untuk fetch | +| `createRelayResponse(response)` | Buat Response dari fetch result | + +## Development + +```bash +bun test # Run tests +bun run index.ts # Start local server (untuk manual testing) +``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..af1f5ef --- /dev/null +++ b/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "proxy-vercel", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + } +} diff --git a/index.test.ts b/index.test.ts new file mode 100644 index 0000000..54a8c02 --- /dev/null +++ b/index.test.ts @@ -0,0 +1,251 @@ +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/index.ts b/index.ts new file mode 100644 index 0000000..ff9eeb7 --- /dev/null +++ b/index.ts @@ -0,0 +1,27 @@ +import { + normalizeTargetUrl, + stripRelayHeaders, + buildRelayRequest, + createRelayResponse, +} from "./relay-utils"; + +export const config = { runtime: "edge" }; + +export default async function handler(req: Request): Promise { + 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" }, + }); + } + + const headers = stripRelayHeaders(new Headers(req.headers)); + const fetchOptions = buildRelayRequest(req, targetUrl, headers); + + const response = await fetch(targetUrl, fetchOptions); + return createRelayResponse(response); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..208ff7f --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "proxy-vercel", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/relay-utils.ts b/relay-utils.ts new file mode 100644 index 0000000..9beb2d8 --- /dev/null +++ b/relay-utils.ts @@ -0,0 +1,40 @@ +export interface RelayOptions { + stripHeaders?: string[]; +} + +export function normalizeTargetUrl(target: string | null, relayPath: string): string | null { + if (!target) return null; + return target.replace(/\/$/, "") + relayPath; +} + +export function stripRelayHeaders(headers: Headers): Headers { + const stripped = new Headers(headers); + stripped.delete("x-relay-target"); + stripped.delete("x-relay-path"); + stripped.delete("host"); + return stripped; +} + +export function shouldSendBody(method: string): boolean { + return method !== "GET" && method !== "HEAD"; +} + +export function buildRelayRequest( + req: Request, + targetUrl: string, + headers: Headers +): RequestInit { + return { + method: req.method, + headers, + body: shouldSendBody(req.method) ? req.body : undefined, + duplex: "half" as const, + }; +} + +export function createRelayResponse(response: Response): Response { + return new Response(response.body, { + status: response.status, + headers: response.headers, + }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b2e7497 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..9499a5f --- /dev/null +++ b/vercel.json @@ -0,0 +1 @@ +{"rewrites":[{"source":"/(.*)","destination":"/api/relay"}]} \ No newline at end of file