router / src /index.ts
xinxiang.wang
../..
53cc475
// Import Bun directly
import { serve } from 'bun';
const PORT = process.env.PORT || 8002; // Changed to 8002 to avoid conflicts
// Hop-by-hop headers (lowercase) - these should not be directly proxied
// As per RFC 2616 Section 13.5.1 and RFC 7230 Section 6.1
const hopByHopHeaders = [
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailers',
'transfer-encoding',
'upgrade',
// 'host' is special, we set it based on the target URL
];
console.log(`Starting URL Path Proxy Server on http://localhost:${PORT}...`);
serve({
port: PORT,
async fetch(req: Request): Promise<Response> {
const incomingUrl = new URL(req.url);
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] Incoming: ${req.method} ${incomingUrl.pathname}${incomingUrl.search}`);
console.log(`[${timestamp}] Request headers:`, Object.fromEntries(req.headers.entries()));
// Special cases for common browser requests and static resources
// 1. Handle socket.io requests
if (incomingUrl.pathname.startsWith('/socket.io/')) {
return new Response('Socket.IO requests are not supported by this proxy.', {
status: 400,
headers: { 'Content-Type': 'text/plain' }
});
}
// 2. Handle favicon.ico requests
if (incomingUrl.pathname === '/favicon.ico') {
return new Response('No favicon available', {
status: 404,
headers: { 'Content-Type': 'text/plain' }
});
}
// 3. Handle other common static resources that browsers might request
// js|css|ico|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot
if (incomingUrl.pathname.match(/\.(ooo|ttt|xxx)$/) ||
incomingUrl.pathname.startsWith('/xxx-devportal/') ||
incomingUrl.pathname.startsWith('/xxx-navigator/') ||
incomingUrl.pathname.substring(1) === 'not-a-valid-url') {
return new Response(`Resource not found: ${incomingUrl.pathname}`, {
status: 404,
headers: { 'Content-Type': 'text/plain' }
});
}
// 1. Extract the full target URL from the *path*
// Example: /https://api.real.io:3000/v1/models?query=1
// ^----------------------------------------^ pathname
// ^-----^ search
// We need "https://api.real.io:3000/v1/models?query=1"
// Remove the leading slash from the pathname
let targetUrlString = incomingUrl.pathname.substring(1);
// If the original request had query parameters, append them to the target URL string
if (incomingUrl.search) {
targetUrlString += incomingUrl.search;
}
if (!targetUrlString) {
return new Response(
"Usage: Send request to http://<proxy_host>:<proxy_port>/<full_target_url>\n" +
"Example: http://localhost:8000/https://api.example.com/data?id=123",
{
status: 400,
headers: { 'Content-Type': 'text/plain' }
}
);
}
// Decode the target URL string in case it was URL-encoded within the path
try {
targetUrlString = decodeURIComponent(targetUrlString);
} catch (e) {
console.error(`[${timestamp}] Failed to decode target URL component: ${targetUrlString}`, e);
return new Response("Invalid URL encoding in the path parameter.", { status: 400 });
}
// 2. Validate and Parse the Target URL
let targetUrl: URL;
try {
targetUrl = new URL(targetUrlString);
// Allow only http and https protocols
if (!['http:', 'https:'].includes(targetUrl.protocol)) {
throw new Error('Invalid protocol');
}
} catch (e) {
console.error(`[${timestamp}] Invalid Target URL Parsed: ${targetUrlString}`, e);
return new Response(`Invalid target URL provided in path: ${targetUrlString}`, { status: 400 });
}
// 3. Prepare Headers for the onward request (to the target server)
const proxyRequestHeaders = new Headers();
req.headers.forEach((value, key) => {
const lowerKey = key.toLowerCase();
// Filter out hop-by-hop headers and the original Host header
if (!hopByHopHeaders.includes(lowerKey) && lowerKey !== 'host') {
proxyRequestHeaders.set(key, value);
}
});
// Add standard forwarding headers (optional but good practice)
// Use 'x-forwarded-for' if already present (multiple proxies)
const clientIp = req.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown'; // Bun doesn't expose socket remoteAddress directly in fetch handler easily yet
proxyRequestHeaders.set('X-Forwarded-For', clientIp);
// Use the protocol the client used to connect to *this* proxy
proxyRequestHeaders.set('X-Forwarded-Proto', incomingUrl.protocol.slice(0, -1));
// Use the host the client used to connect to *this* proxy
proxyRequestHeaders.set('X-Forwarded-Host', incomingUrl.host);
// Set the Host header correctly for the *target* server
proxyRequestHeaders.set('Host', targetUrl.host); // targetUrl.host includes hostname and port if non-standard
console.log(`[${timestamp}] Proxying to: ${targetUrl.toString()}`);
// For debugging headers being sent to target:
console.log("Headers sent to target:", Object.fromEntries(proxyRequestHeaders.entries()));
console.log("Request body:", req.body);
// 4. Make the actual request to the target URL using fetch
try {
// Clone the request to get the body as a stream
let requestBody = null;
// Only include body for methods that typically have one
if (req.method !== 'GET' && req.method !== 'HEAD') {
// Clone the request to get access to the body
const clonedReq = req.clone();
// Get the body as a string or buffer
try {
// Try to get the body as JSON first
const contentType = req.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
let bodyText = await clonedReq.text();
// If body is empty, try to read it directly from the request
if (!bodyText && req.body) {
try {
// Create a new request to avoid disturbing the original
const tempReq = new Request(req.url, {
method: req.method,
headers: req.headers,
body: req.body,
duplex: 'half'
});
bodyText = await tempReq.text();
} catch (e) {
console.error(`[${timestamp}] Error reading body directly:`, e);
}
}
console.log(`[${timestamp}] Request body (JSON):`, bodyText);
// Check if this is an OpenAI-compatible streaming request
try {
const jsonBody = JSON.parse(bodyText);
if (jsonBody.stream === true) {
console.log(`[${timestamp}] Detected OpenAI streaming request`);
// Make sure we set the proper headers for streaming
if (!proxyRequestHeaders.has('Accept')) {
proxyRequestHeaders.set('Accept', 'text/event-stream');
}
}
} catch (jsonError) {
// Not valid JSON or couldn't parse, ignore
}
requestBody = bodyText;
} else {
// For other content types, use the body as is
const bodyBuffer = await clonedReq.arrayBuffer();
console.log(`[${timestamp}] Request body (binary): ${bodyBuffer.byteLength} bytes`);
requestBody = bodyBuffer;
}
} catch (bodyError) {
console.error(`[${timestamp}] Error reading request body:`, bodyError);
// Continue without body if we can't read it
}
}
const proxyResponse = await fetch(targetUrl.toString(), {
method: req.method,
headers: proxyRequestHeaders,
body: requestBody,
redirect: 'manual', // Let the client handle redirects based on Location header
// Or use 'follow' if you want the proxy to handle them transparently
// 'manual' is often safer for a generic proxy
duplex: 'half', // Important for streaming responses
});
console.log(`[${timestamp}] Target response: ${proxyResponse.status} ${proxyResponse.statusText}`);
console.log(`[${timestamp}] Response headers:`, Object.fromEntries(proxyResponse.headers.entries()));
// 5. Prepare headers for the response back to the original client
const clientResponseHeaders = new Headers();
proxyResponse.headers.forEach((value, key) => {
const lowerKey = key.toLowerCase();
// Filter out hop-by-hop headers from the target's response
if (!hopByHopHeaders.includes(lowerKey)) {
clientResponseHeaders.set(key, value);
}
});
// Optional: Add a Via header to indicate the request went through this proxy
clientResponseHeaders.append('Via', `1.1 bun-url-proxy`);
// 6. Return the response to the original client (streaming)
// Check if this is a streaming response (e.g., SSE or chunked transfer)
const responseContentType = proxyResponse.headers.get('content-type') || '';
const transferEncoding = proxyResponse.headers.get('transfer-encoding') || '';
// Check for streaming responses, including OpenAI-compatible streaming
const isStreaming = responseContentType.includes('text/event-stream') ||
transferEncoding.includes('chunked') ||
responseContentType.includes('application/x-ndjson') ||
// Check for OpenAI streaming responses
(targetUrl.toString().includes('/chat/completions') &&
req.url.includes('stream=true'));
if (isStreaming) {
console.log(`[${timestamp}] Detected streaming response: ${responseContentType}`);
// Ensure we preserve the streaming nature of the response
return new Response(proxyResponse.body, {
status: proxyResponse.status,
statusText: proxyResponse.statusText,
headers: clientResponseHeaders,
});
} else {
// For non-streaming responses, we can just pass through the body
return new Response(proxyResponse.body, {
status: proxyResponse.status,
statusText: proxyResponse.statusText,
headers: clientResponseHeaders,
});
}
} catch (error: any) {
console.error(`[${timestamp}] Proxy Request Error for ${targetUrl.toString()}:`, error.message, error.cause ? `Cause: ${error.cause}` : '');
// Common errors: ECONNREFUSED (server down), ENOTFOUND (DNS issue), fetch errors
// Return 502 Bad Gateway for network/connection issues
return new Response(`Proxy error: Could not connect to target server at ${targetUrl.host}. ${error.message}`, { status: 502 });
}
},
// Global error handler for the server itself
error(error: Error): Response {
console.error("Server Error:", error);
return new Response("Internal Server Error", { status: 500 });
},
});
console.log(`URL Path Proxy Server is ready and listening on http://localhost:${PORT}`);
console.log(`Example Usage: curl http://localhost:${PORT}/https://httpbin.org/get`);
console.log(`Example Usage with Port: curl http://localhost:${PORT}/https://api.real.io:3000/v1/models`);
// Regarding Concurrency:
// Bun's server and fetch API are built on an efficient asynchronous event loop.
// It's designed for high concurrency out-of-the-box.
// No special code is needed to handle 16 or many more concurrent requests.
// Performance will depend on the underlying machine resources and the responsiveness
// of the target servers being proxied.