refactor: migrate to Next.js with edge functions and update relay utilities
- Removed old relay utilities and tests, replacing them with a new structure under `src/lib/relay-utils.ts`. - Implemented a new edge function in `app/api/proxy/route.ts` to handle proxy requests. - Created a new Next.js layout and page structure for the frontend, including a user interface for testing the proxy. - Added Tailwind CSS for styling and configured PostCSS. - Updated TypeScript configuration for better compatibility with Next.js. - Removed unused files and configurations related to the previous setup.
This commit is contained in:
@@ -4,13 +4,21 @@ import {
|
||||
normalizeTargetUrl,
|
||||
stripRelayHeaders,
|
||||
isAllowedTarget,
|
||||
} from "../relay-utils";
|
||||
} from "@/lib/relay-utils";
|
||||
|
||||
export const config = { runtime: "edge" };
|
||||
export const runtime = "edge";
|
||||
|
||||
const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]);
|
||||
|
||||
export default async function handler(req: Request): Promise<Response> {
|
||||
export async function POST(req: Request) { return await handler(req); }
|
||||
export async function GET(req: Request) { return await handler(req); }
|
||||
export async function PUT(req: Request) { return await handler(req); }
|
||||
export async function DELETE(req: Request) { return await handler(req); }
|
||||
export async function PATCH(req: Request) { return await handler(req); }
|
||||
export async function HEAD(req: Request) { return await handler(req); }
|
||||
export async function OPTIONS(req: Request) { return await handler(req); }
|
||||
|
||||
async function handler(req: Request): Promise<Response> {
|
||||
if (!ALLOWED_METHODS.has(req.method)) {
|
||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||
status: 405,
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
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">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Home() {
|
||||
const [targetUrl, setTargetUrl] = useState("https://httpbin.org/anything");
|
||||
const [method, setMethod] = useState("GET");
|
||||
const [requestBody, setRequestBody] = useState(JSON.stringify({
|
||||
message: "Hello from Edge Proxy",
|
||||
timestamp: Date.now(),
|
||||
}, 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('/api/proxy', 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,
|
||||
statusText: res.statusText,
|
||||
time: `${elapsed}ms`,
|
||||
body: responseText,
|
||||
headers: JSON.stringify(headersObj, null, 2)
|
||||
});
|
||||
} catch (err: any) {
|
||||
setResponse({
|
||||
status: 'Error',
|
||||
body: err.message,
|
||||
headers: '-'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 text-slate-200 p-8 max-w-6xl mx-auto font-sans">
|
||||
<h1 className="text-3xl font-bold text-sky-400 mb-2">Edge Proxy Relay Test</h1>
|
||||
<p className="text-slate-400 mb-8">Standardized Next.js Migration</p>
|
||||
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 mb-6">
|
||||
<h2 className="text-sky-400 text-sm font-bold uppercase tracking-wider mb-4">Target Configuration</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 font-bold uppercase mb-2">Target URL</label>
|
||||
<input
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-lg p-3 font-mono text-sm focus:border-sky-500 outline-none"
|
||||
value={targetUrl}
|
||||
onChange={(e) => setTargetUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{['https://httpbin.org/anything', 'https://httpbin.org/get', 'https://jsonplaceholder.typicode.com/posts/1'].map(url => (
|
||||
<button
|
||||
key={url}
|
||||
className="text-xs bg-slate-700 hover:bg-slate-600 px-3 py-1 rounded transition"
|
||||
onClick={() => setTargetUrl(url)}
|
||||
>
|
||||
{url.split('/').pop() || url}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 mb-6">
|
||||
<h2 className="text-sky-400 text-sm font-bold uppercase tracking-wider mb-4">Method</h2>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{methods.map(m => (
|
||||
<button
|
||||
key={m}
|
||||
className={`px-4 py-2 rounded font-bold text-sm transition ${method === m ? 'bg-sky-500 text-slate-900' : 'bg-slate-700 hover:bg-slate-600'}`}
|
||||
onClick={() => setMethod(m)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||
<h2 className="text-sky-400 text-sm font-bold uppercase tracking-wider mb-4">Body (JSON)</h2>
|
||||
<textarea
|
||||
className="w-full h-48 bg-slate-950 border border-slate-700 rounded-lg p-3 font-mono text-xs focus:border-sky-500 outline-none resize-none"
|
||||
value={requestBody}
|
||||
onChange={(e) => setRequestBody(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||
<h2 className="text-sky-400 text-sm font-bold uppercase tracking-wider mb-4">Custom Headers</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 font-bold uppercase mb-2">Key</label>
|
||||
<input
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-lg p-3 font-mono text-sm focus:border-sky-500 outline-none"
|
||||
placeholder="x-custom-header"
|
||||
value={headerKey}
|
||||
onChange={(e) => setHeaderKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 font-bold uppercase mb-2">Value</label>
|
||||
<input
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-lg p-3 font-mono text-sm focus:border-sky-500 outline-none"
|
||||
placeholder="value"
|
||||
value={headerValue}
|
||||
onChange={(e) => setHeaderValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full py-4 bg-sky-500 hover:bg-sky-400 text-slate-900 font-bold rounded-xl transition shadow-lg shadow-sky-500/20 disabled:opacity-50"
|
||||
onClick={sendRequest}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Sending..." : "Send Request"}
|
||||
</button>
|
||||
|
||||
{response && (
|
||||
<div className="mt-8 space-y-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="bg-slate-800 px-4 py-2 rounded-lg border border-slate-700">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase mr-2">Status:</span>
|
||||
<span className={`font-bold ${response.status >= 200 && response.status < 300 ? 'text-green-400' : 'text-orange-400'}`}>
|
||||
{response.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-slate-800 px-4 py-2 rounded-lg border border-slate-700">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase mr-2">Time:</span>
|
||||
<span className="font-bold text-slate-200">{response.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 font-bold uppercase mb-2">Response Body</label>
|
||||
<pre className="bg-slate-950 p-4 rounded-lg border border-slate-700 overflow-auto max-h-96 text-xs text-green-400 font-mono">
|
||||
{response.body}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 font-bold uppercase mb-2">Response Headers</label>
|
||||
<pre className="bg-slate-950 p-4 rounded-lg border border-slate-700 overflow-auto max-h-96 text-xs text-slate-400 font-mono">
|
||||
{response.headers}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,17 @@
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "proxy-vercel",
|
||||
"dependencies": {
|
||||
"next": "^16.2.5",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@types/bun": "latest",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^4.2.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -29,12 +37,136 @@
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.14", "", { "os": "win32", "cpu": "x64" }, "sha512-WL0EG5qE+EAKomGXbf2g6VnSKJhTL3tXC0QRzWRwA5VpjxNYa6H4P7ZWfymbGE4IhZZQi1KXQ2R0YjwInmz2fA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@next/env": ["@next/env@16.2.5", "", {}, "sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BW+8PGVmsruomXHsitD8JG6gny9lEdobctjBwvtPF8AKtxGDR7nR35FOl/oK9UAPXBOBm+vx0k8qtpeHOXQMGQ=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZoCGnCl9LlQJWmqXrZAUlNxvuNmclvE+7zUif+nDydkkehl9FKxHJ+wxSQMj+C37BYFerKiEdX9s9o02ir975Q=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-AwcZzMChaWkOTZt3vu+2ZMIj8g4dYQY+B8VUVhlFSQ2JtvyZpefyYHTe00D6b6L7BysYw7vl3zsvs9jix8tl5Q=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-QqMgqWbCBFsfiQ7BF3dUlW8HJy1LWhpcqbTpoHMWA9IV+TnWwDKozQJA5NdIAHjQ00yX2Q7AUkLr/XK4n77q8A=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-3hzeiFGZtyATVx9pCeuzTshXmh50vHZitqaeZiyJZaUmjQyrfjsVUgS8apOj1vEJCIpKJM/55F45yPAV2kpjsA=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-0mzZV/mAt7Qj2tYNdTB6AqrS8dwng/AQLSYC5Z1YLpZdi2wxqKDPK7RY2RvjB1fXyJfOfdA3l/yTF5yLi+WfuQ=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-f/H4nZ2zJBvA8/+HpsB9mNonF9zfQoAU6D0WxJrfzhJDvJLfngVN85oqxUyrDVK99DIFfFYhLpGa5K+c5uotSw=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-nuP7DHs4koAojsIxVPkihNgKiRUKtCU65j5X6DAbSy8VBrfT/o90bCLLHPf51JEdOZwZMFzM6e0NiGWfIWjVAg=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.352", "", {}, "sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
|
||||
"next": ["next@16.2.5", "", { "dependencies": { "@next/env": "16.2.5", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.5", "@next/swc-darwin-x64": "16.2.5", "@next/swc-linux-arm64-gnu": "16.2.5", "@next/swc-linux-arm64-musl": "16.2.5", "@next/swc-linux-x64-gnu": "16.2.5", "@next/swc-linux-x64-musl": "16.2.5", "@next/swc-win32-arm64-msvc": "16.2.5", "@next/swc-win32-x64-msvc": "16.2.5", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-TkVTm9F2WEulkgGljm4wPwNgvCCWCVw6StUHsZb8WZpHFRjepoUWg3d7L4IMg7IyjcJ4Co9eVhpro8e8O+KarQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import {
|
||||
normalizeTargetUrl,
|
||||
stripRelayHeaders,
|
||||
buildRelayRequest,
|
||||
createRelayResponse,
|
||||
} from "./relay-utils";
|
||||
|
||||
export const config = { runtime: "edge" };
|
||||
|
||||
export default async function handler(req: Request): Promise<Response> {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
+14
-8
@@ -3,16 +3,22 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "mkdir -p dist && cp public/index.html dist/index.html",
|
||||
"start": "bun run src/index.ts",
|
||||
"dev": "bun --hot run src/index.ts",
|
||||
"test": "bun test",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"check": "bunx tsc --noEmit"
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "bun test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@types/bun": "latest"
|
||||
"@types/bun": "latest",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^4.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^16.2.5",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,542 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Edge Proxy Test</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
padding: 2rem;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #38bdf8;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #94a3b8;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #1e293b;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
color: #38bdf8;
|
||||
font-size: 1rem;
|
||||
margin: 0 0 1rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
color: #e2e8f0;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: #38bdf8;
|
||||
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 150px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.method-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.method-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #334155;
|
||||
border: 1px solid #475569;
|
||||
border-radius: 6px;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.method-btn:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.method-btn.active {
|
||||
background: #38bdf8;
|
||||
border-color: #38bdf8;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.method-btn.get.active { background: #22c55e; border-color: #22c55e; }
|
||||
.method-btn.post.active { background: #f97316; border-color: #f97316; }
|
||||
.method-btn.put.active { background: #3b82f6; border-color: #3b82f6; }
|
||||
.method-btn.delete.active { background: #ef4444; border-color: #ef4444; }
|
||||
|
||||
.send-btn {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #38bdf8, #0ea5e9);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #0f172a;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.send-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(56, 189, 248, 0.4);
|
||||
}
|
||||
|
||||
.send-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.response-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.response-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.response-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #0f172a;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.meta-badge .label {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.meta-badge .value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-2xx { color: #22c55e; }
|
||||
.status-3xx { color: #eab308; }
|
||||
.status-4xx { color: #f97316; }
|
||||
.status-5xx { color: #ef4444; }
|
||||
|
||||
pre {
|
||||
background: #0f172a;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef4444;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.headers-list {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.headers-list div {
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.headers-list div:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid #0f172a;
|
||||
border-top-color: #38bdf8;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.quick-urls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.quick-url {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: #334155;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.quick-url:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.presets {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: #334155;
|
||||
border: 1px solid #475569;
|
||||
border-radius: 4px;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.preset-btn.active {
|
||||
background: #38bdf8;
|
||||
border-color: #38bdf8;
|
||||
color: #0f172a;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Edge Proxy Relay Test</h1>
|
||||
<p class="subtitle">Test your proxy with various endpoints and configurations</p>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Target Configuration</h2>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Target URL</label>
|
||||
<input type="text" id="targetUrl" value="https://httpbin.org/anything" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-urls">
|
||||
<span style="color: #64748b; font-size: 0.75rem;">Quick URLs:</span>
|
||||
<button class="quick-url" onclick="setUrl('https://httpbin.org/anything')">httpbin /anything</button>
|
||||
<button class="quick-url" onclick="setUrl('https://httpbin.org/get')">httpbin /get</button>
|
||||
<button class="quick-url" onclick="setUrl('https://httpbin.org/post')">httpbin /post</button>
|
||||
<button class="quick-url" onclick="setUrl('https://httpbin.org/headers')">httpbin /headers</button>
|
||||
<button class="quick-url" onclick="setUrl('https://httpbin.org/uuid')">httpbin /uuid</button>
|
||||
<button class="quick-url" onclick="setUrl('https://jsonplaceholder.typicode.com/posts/1')">JSONPlaceholder</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Request Method</h2>
|
||||
<div class="method-buttons">
|
||||
<button class="method-btn get active" data-method="GET" onclick="setMethod('GET')">GET</button>
|
||||
<button class="method-btn post" data-method="POST" onclick="setMethod('POST')">POST</button>
|
||||
<button class="method-btn put" data-method="PUT" onclick="setMethod('PUT')">PUT</button>
|
||||
<button class="method-btn delete" data-method="DELETE" onclick="setMethod('DELETE')">DELETE</button>
|
||||
<button class="method-btn" data-method="PATCH" onclick="setMethod('PATCH')">PATCH</button>
|
||||
<button class="method-btn" data-method="HEAD" onclick="setMethod('HEAD')">HEAD</button>
|
||||
<button class="method-btn" data-method="OPTIONS" onclick="setMethod('OPTIONS')">OPTIONS</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Request Body (JSON)</h2>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex: 2;">
|
||||
<textarea id="requestBody" placeholder='{"key": "value"}'>{
|
||||
"message": "Hello from Edge Proxy",
|
||||
"timestamp": 1234567890,
|
||||
"nested": {
|
||||
"array": [1, 2, 3]
|
||||
}
|
||||
}</textarea>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label>Presets</label>
|
||||
<div class="presets">
|
||||
<button type="button" class="preset-btn" onclick="setBody('empty')">Empty</button>
|
||||
<button type="button" class="preset-btn" onclick="setBody('simple')">Simple</button>
|
||||
<button type="button" class="preset-btn" onclick="setBody('form')">Form Data</button>
|
||||
<button type="button" class="preset-btn" onclick="setBody('large')">Large JSON</button>
|
||||
</div>
|
||||
<label>Format</label>
|
||||
<button type="button" class="preset-btn" onclick="formatJson()">Format JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Custom Headers</h2>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Header Key</label>
|
||||
<input type="text" id="headerKey" placeholder="x-custom-header" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Header Value</label>
|
||||
<input type="text" id="headerValue" placeholder="custom-value" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="send-btn" id="sendBtn" onclick="sendRequest()">
|
||||
Send Request
|
||||
</button>
|
||||
|
||||
<div class="panel" style="margin-top: 2rem;">
|
||||
<h2>Response</h2>
|
||||
<div class="response-meta" id="responseMeta">
|
||||
<div class="meta-badge">
|
||||
<span class="label">Status:</span>
|
||||
<span class="value" id="statusBadge">-</span>
|
||||
</div>
|
||||
<div class="meta-badge">
|
||||
<span class="label">Time:</span>
|
||||
<span class="value" id="timeBadge">-</span>
|
||||
</div>
|
||||
<div class="meta-badge">
|
||||
<span class="label">Size:</span>
|
||||
<span class="value" id="sizeBadge">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="response-section">
|
||||
<div>
|
||||
<label style="color: #64748b; font-size: 0.75rem; margin-bottom: 0.5rem; display: block;">Response Body</label>
|
||||
<pre id="responseBody">Response will appear here...</pre>
|
||||
</div>
|
||||
<div>
|
||||
<label style="color: #64748b; font-size: 0.75rem; margin-bottom: 0.5rem; display: block;">Response Headers</label>
|
||||
<pre id="responseHeaders" class="headers-list">Response headers will appear here...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentMethod = 'GET';
|
||||
|
||||
function setMethod(method) {
|
||||
currentMethod = method;
|
||||
document.querySelectorAll('.method-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.querySelectorAll('.method-btn').forEach(btn => {
|
||||
if (btn.dataset.method === method) btn.classList.add('active');
|
||||
});
|
||||
}
|
||||
|
||||
function setUrl(url) {
|
||||
document.getElementById('targetUrl').value = url;
|
||||
}
|
||||
|
||||
function setBody(type) {
|
||||
const textarea = document.getElementById('requestBody');
|
||||
switch (type) {
|
||||
case 'empty':
|
||||
textarea.value = '';
|
||||
break;
|
||||
case 'simple':
|
||||
textarea.value = JSON.stringify({ hello: 'world' }, null, 2);
|
||||
break;
|
||||
case 'form':
|
||||
textarea.value = JSON.stringify({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
message: 'Hello!'
|
||||
}, null, 2);
|
||||
break;
|
||||
case 'large':
|
||||
textarea.value = JSON.stringify({
|
||||
users: Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `User ${i + 1}`,
|
||||
email: `user${i + 1}@example.com`,
|
||||
active: i % 2 === 0
|
||||
}))
|
||||
}, null, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function formatJson() {
|
||||
const textarea = document.getElementById('requestBody');
|
||||
try {
|
||||
const parsed = JSON.parse(textarea.value);
|
||||
textarea.value = JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
alert('Invalid JSON: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (status >= 200 && status < 300) return 'status-2xx';
|
||||
if (status >= 300 && status < 400) return 'status-3xx';
|
||||
if (status >= 400 && status < 500) return 'status-4xx';
|
||||
return 'status-5xx';
|
||||
}
|
||||
|
||||
async function sendRequest() {
|
||||
const btn = document.getElementById('sendBtn');
|
||||
const targetUrl = document.getElementById('targetUrl').value;
|
||||
const requestBody = document.getElementById('requestBody').value;
|
||||
const headerKey = document.getElementById('headerKey').value;
|
||||
const headerValue = document.getElementById('headerValue').value;
|
||||
|
||||
// Update UI
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Sending...';
|
||||
document.getElementById('responseBody').textContent = 'Sending request...';
|
||||
document.getElementById('responseHeaders').textContent = '-';
|
||||
document.getElementById('statusBadge').textContent = '-';
|
||||
document.getElementById('timeBadge').textContent = '-';
|
||||
document.getElementById('sizeBadge').textContent = '-';
|
||||
|
||||
const headers = {
|
||||
'x-relay-target': targetUrl,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (headerKey && headerValue) {
|
||||
headers[headerKey] = headerValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const fetchOptions = {
|
||||
method: currentMethod,
|
||||
headers
|
||||
};
|
||||
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(currentMethod) && requestBody) {
|
||||
fetchOptions.body = requestBody;
|
||||
}
|
||||
|
||||
const res = await fetch('/api', fetchOptions);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
// Parse response
|
||||
let responseText = '';
|
||||
let responseData = null;
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
responseData = await res.json();
|
||||
responseText = JSON.stringify(responseData, null, 2);
|
||||
} else {
|
||||
responseText = await res.text();
|
||||
}
|
||||
|
||||
// Get response headers
|
||||
const headersObj = {};
|
||||
res.headers.forEach((value, key) => {
|
||||
headersObj[key] = value;
|
||||
});
|
||||
|
||||
// Update UI
|
||||
const statusEl = document.getElementById('statusBadge');
|
||||
statusEl.textContent = `${res.status} ${res.statusText}`;
|
||||
statusEl.className = `value ${getStatusClass(res.status)}`;
|
||||
|
||||
document.getElementById('timeBadge').textContent = `${elapsed}ms`;
|
||||
document.getElementById('sizeBadge').textContent = formatBytes(new Blob([responseText]).size);
|
||||
document.getElementById('responseBody').textContent = responseText;
|
||||
document.getElementById('responseHeaders').textContent = JSON.stringify(headersObj, null, 2);
|
||||
|
||||
} catch (err) {
|
||||
document.getElementById('responseBody').innerHTML = `<span class="error-text">Error: ${err.message}</span>`;
|
||||
document.getElementById('statusBadge').textContent = 'Error';
|
||||
document.getElementById('statusBadge').className = 'value status-5xx';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Send Request';
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,257 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
buildRelayRequest,
|
||||
createRelayResponse,
|
||||
normalizeTargetUrl,
|
||||
stripRelayHeaders,
|
||||
isAllowedTarget,
|
||||
} from "~/relay-utils";
|
||||
|
||||
export const config = { runtime: "edge" };
|
||||
|
||||
// Only allow safe methodsa
|
||||
const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]);
|
||||
|
||||
export default async function handler(req: Request): Promise<Response> {
|
||||
// Method validation
|
||||
if (!ALLOWED_METHODS.has(req.method)) {
|
||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||
status: 405,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Target validation (SSRF prevention)
|
||||
if (!isAllowedTarget(targetUrl)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Target domain not allowed" }),
|
||||
{
|
||||
status: 403,
|
||||
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);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// Allowlist: only these headers are forwarded to prevent leaking
|
||||
// Vercel internal metadata, credentials, and infrastructure info
|
||||
const ALLOWED_HEADERS = new Set([
|
||||
// Standard request headers
|
||||
"content-type",
|
||||
"accept",
|
||||
"accept-encoding",
|
||||
"accept-language",
|
||||
"user-agent",
|
||||
"referer",
|
||||
"origin",
|
||||
// Auth headers (but NOT cookies)
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
// Content negotiation
|
||||
"cache-control",
|
||||
// Custom headers (no prefix restriction, but Vercel-specific are blocked)
|
||||
]);
|
||||
|
||||
// Blocklist: sensitive headers that should NEVER be forwarded
|
||||
const BLOCKED_HEADERS = new Set([
|
||||
// Vercel infrastructure headers
|
||||
"x-vercel-id",
|
||||
"x-vercel-deployment-url",
|
||||
"x-vercel-oidc-token",
|
||||
"x-vercel-oidc-token-ts",
|
||||
"x-vercel-signature",
|
||||
"x-vercel-edgified",
|
||||
"x-vercel-ip-city",
|
||||
"x-vercel-ip-country",
|
||||
"x-vercel-ip-country-region",
|
||||
"x-vercel-ip-latency",
|
||||
"x-vercel-deployment-config",
|
||||
"x-vercel-rewritten-query",
|
||||
// Cloudflare specific
|
||||
"cf-ray",
|
||||
"cf-connecting-ip",
|
||||
"cf-ipcountry",
|
||||
"cf-ray-id",
|
||||
// Forwarding proxies (can leak internal network info)
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-proto",
|
||||
"forwarded",
|
||||
// Cookies (should be explicitly handled, not blindly forwarded)
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
// Internal infrastructure
|
||||
"x-real-ip",
|
||||
"x-cluster-client-ip",
|
||||
// Authentication tokens
|
||||
"x-api-key",
|
||||
// Caching
|
||||
"x-cache",
|
||||
]);
|
||||
|
||||
// Internal relay headers
|
||||
const INTERNAL_HEADERS = new Set([
|
||||
"x-relay-target",
|
||||
"x-relay-path",
|
||||
"host",
|
||||
]);
|
||||
|
||||
export interface RelayOptions {
|
||||
stripHeaders?: string[];
|
||||
}
|
||||
|
||||
export function isAllowedTarget(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
// Only allow HTTP/HTTPS (prevents file://, data:, etc.)
|
||||
return ["http:", "https:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeTargetUrl(
|
||||
target: string | null,
|
||||
relayPath: string,
|
||||
): string | null {
|
||||
if (!target) return null;
|
||||
return target.replace(/\/$/, "") + relayPath;
|
||||
}
|
||||
|
||||
export function filterHeaders(headers: Headers): Headers {
|
||||
const filtered = new Headers();
|
||||
|
||||
for (const [key, value] of headers.entries()) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
// Skip internal relay headers
|
||||
if (INTERNAL_HEADERS.has(lowerKey)) continue;
|
||||
|
||||
// Skip blocked headers (security critical)
|
||||
if (BLOCKED_HEADERS.has(lowerKey)) continue;
|
||||
|
||||
// Block headers with sensitive infrastructure prefixes
|
||||
if (lowerKey.startsWith("x-vercel-")) continue;
|
||||
if (lowerKey.startsWith("cf-")) continue;
|
||||
if (lowerKey.startsWith("x-forwarded-")) continue;
|
||||
|
||||
// For known safe headers, always allow
|
||||
if (ALLOWED_HEADERS.has(lowerKey)) {
|
||||
filtered.set(key, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allow custom headers (no sensitive prefix)
|
||||
// Custom headers typically use kebab-case (e.g., x-custom-header)
|
||||
filtered.set(key, value);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export function stripRelayHeaders(headers: Headers): Headers {
|
||||
// Use the secure filter instead of manual deletion
|
||||
return filterHeaders(headers);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
+25
-28
@@ -1,30 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"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,
|
||||
|
||||
// Path aliases
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"bunVersion": "1",
|
||||
"installCommand": "bun install",
|
||||
"outputDirectory": "dist",
|
||||
"trailingSlash": false,
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/api",
|
||||
"destination": "/api/index.ts"
|
||||
},
|
||||
{
|
||||
"source": "/api/(.*)",
|
||||
"destination": "/api/index.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user