From a4fcbbcca92256fb8df5a160f30215a98ec82d81 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 7 May 2026 18:42:09 +0700 Subject: [PATCH] initial commit for proxy-bun --- .gitignore | 34 +++++++ CLAUDE.md | 106 +++++++++++++++++++++ README.md | 74 +++++++++++++++ bun.lock | 26 +++++ index.test.ts | 251 +++++++++++++++++++++++++++++++++++++++++++++++++ index.ts | 27 ++++++ package.json | 12 +++ relay-utils.ts | 40 ++++++++ tsconfig.json | 30 ++++++ vercel.json | 1 + 10 files changed, 601 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 bun.lock create mode 100644 index.test.ts create mode 100644 index.ts create mode 100644 package.json create mode 100644 relay-utils.ts create mode 100644 tsconfig.json create mode 100644 vercel.json 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