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
+11 -3
View File
@@ -4,13 +4,21 @@ import {
normalizeTargetUrl, normalizeTargetUrl,
stripRelayHeaders, stripRelayHeaders,
isAllowedTarget, 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"]); 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)) { if (!ALLOWED_METHODS.has(req.method)) {
return new Response(JSON.stringify({ error: "Method not allowed" }), { return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405, status: 405,
+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>
);
}
+132
View File
File diff suppressed because one or more lines are too long
-27
View File
@@ -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);
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+14 -8
View File
@@ -3,16 +3,22 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "mkdir -p dist && cp public/index.html dist/index.html", "dev": "next dev",
"start": "bun run src/index.ts", "build": "next build",
"dev": "bun --hot run src/index.ts", "start": "next start",
"test": "bun test", "lint": "next lint",
"lint": "biome check .", "test": "bun test"
"lint:fix": "biome check --write .",
"check": "bunx tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.14", "@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"
} }
} }
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-542
View File
@@ -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>
-257
View File
@@ -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();
});
});
-53
View File
@@ -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);
}
-143
View File
@@ -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,
});
}
+11
View File
@@ -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
View File
@@ -1,30 +1,27 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": ["ESNext"], "target": "ESNext",
"target": "ESNext", "lib": ["dom", "dom.iterable", "esnext"],
"module": "Preserve", "allowJs": true,
"moduleDetection": "force", "skipLibCheck": true,
"jsx": "react-jsx", "strict": true,
"allowJs": true, "noEmit": true,
"types": ["bun"], "esModuleInterop": true,
"module": "esnext",
// Bundler mode "moduleResolution": "bundler",
"moduleResolution": "bundler", "resolveJsonModule": true,
"allowImportingTsExtensions": true, "isolatedModules": true,
"verbatimModuleSyntax": true, "jsx": "preserve",
"noEmit": true, "incremental": true,
"plugins": [
// Best practices {
"strict": true, "name": "next"
"skipLibCheck": true, }
"noFallthroughCasesInSwitch": true, ],
"noUncheckedIndexedAccess": true, "paths": {
"noImplicitOverride": true, "@/*": ["./src/*"]
}
// Path aliases },
"paths": { "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"~/*": ["./src/*"] "exclude": ["node_modules"]
}
},
"include": ["src/**/*"]
} }
-17
View File
@@ -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"
}
]
}