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:
MythEclipse
2026-05-07 20:59:23 +07:00
parent 4c30f2a6e1
commit b0dde3c885
17 changed files with 427 additions and 1078 deletions
+58
View File
@@ -0,0 +1,58 @@
import {
buildRelayRequest,
createRelayResponse,
normalizeTargetUrl,
stripRelayHeaders,
isAllowedTarget,
} from "@/lib/relay-utils";
export const runtime = "edge";
const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]);
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,
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" },
},
);
}
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, targetUrl, headers);
const response = await fetch(targetUrl, fetchOptions);
return createRelayResponse(response);
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+19
View File
@@ -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
View File
@@ -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>
);
}