From 86ca59ad8ec27cdd1e3a830507fc57f77d444808 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 7 May 2026 19:12:46 +0700 Subject: [PATCH] refactor: restructure project by moving and renaming files, and implementing relay utilities --- src/index.test.ts | 257 +++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 30 ++++++ src/relay-utils.ts | 42 ++++++++ 3 files changed, 329 insertions(+) create mode 100644 src/index.test.ts create mode 100644 src/index.ts create mode 100644 src/relay-utils.ts diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..c2a0f97 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, test } from "bun:test"; +import { + buildRelayRequest, + createRelayResponse, + normalizeTargetUrl, + shouldSendBody, + stripRelayHeaders, +} 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, 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, 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, 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, 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, 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, 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, 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, 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, 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/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2a345e1 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,30 @@ +import { + buildRelayRequest, + createRelayResponse, + normalizeTargetUrl, + stripRelayHeaders, +} 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, headers); + + const response = await fetch(targetUrl, fetchOptions); + return createRelayResponse(response); +} diff --git a/src/relay-utils.ts b/src/relay-utils.ts new file mode 100644 index 0000000..585c4a6 --- /dev/null +++ b/src/relay-utils.ts @@ -0,0 +1,42 @@ +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, + _headers: Headers, +): RequestInit { + return { + method: req.method, + headers: _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, + }); +}