diff --git a/.env.example b/.env.example index 2b13f57..e97236d 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,13 @@ # Google reCAPTCHA Enterprise checkbox site key VITE_RECAPTCHA_SITE_KEY= -# Backend base URLs. Replace these with your deployed HTTPS endpoints. -VITE_BE1_BASE_URL=https://be1-backend.example.com -VITE_BE3_BASE_URL=https://be3-backend.example.com +# Browser-facing backend base paths. In Vercel these route through vercel.json rewrites. +VITE_BE1_BASE_URL=/be1 +VITE_BE3_BASE_URL=/be3 + +# Server-side proxy targets for Vercel Functions. Configure these in Vercel only. +BE1_PROXY_TARGET_URL=https://be1-backend.example.com +BE3_PROXY_TARGET_URL=https://be3-backend.example.com # Shared request timeout VITE_API_TIMEOUT_MS=10000 diff --git a/api/_proxy.js b/api/_proxy.js new file mode 100644 index 0000000..c1a1fd9 --- /dev/null +++ b/api/_proxy.js @@ -0,0 +1,114 @@ +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'content-encoding', + 'content-length', + 'host', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +function buildHeaders(sourceHeaders) { + const headers = new Headers(); + + for (const [name, value] of Object.entries(sourceHeaders)) { + const normalizedName = name.toLowerCase(); + + if (HOP_BY_HOP_HEADERS.has(normalizedName) || value === undefined) { + continue; + } + + headers.set(name, Array.isArray(value) ? value.join(', ') : value); + } + + return headers; +} + +function requireHttpsTarget(envName) { + const rawTarget = process.env[envName]?.trim(); + + if (!rawTarget) { + throw new Error(`${envName} is required.`); + } + + const target = new URL(rawTarget); + + if (target.protocol !== 'https:') { + throw new Error(`${envName} must use an https URL.`); + } + + return target; +} + +function buildTargetUrl(requestUrl, prefix, targetBase) { + const incomingUrl = new URL(requestUrl, 'https://vercel.local'); + const pathname = incomingUrl.pathname.startsWith(prefix) + ? incomingUrl.pathname.slice(prefix.length) + : incomingUrl.pathname; + + const targetUrl = new URL(pathname || '/', targetBase); + targetUrl.search = incomingUrl.search; + + return targetUrl; +} + +async function proxyRequest(request, response, { envName, prefix }) { + let targetUrl; + + try { + targetUrl = buildTargetUrl(request.url, prefix, requireHttpsTarget(envName)); + } catch (error) { + response.statusCode = 500; + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(JSON.stringify({ message: error.message })); + return; + } + + try { + const upstreamResponse = await fetch(targetUrl, { + body: ['GET', 'HEAD'].includes(request.method) ? undefined : request, + duplex: ['GET', 'HEAD'].includes(request.method) ? undefined : 'half', + headers: buildHeaders(request.headers), + method: request.method, + redirect: 'manual', + }); + + response.statusCode = upstreamResponse.status; + + upstreamResponse.headers.forEach((value, name) => { + if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase())) { + response.setHeader(name, value); + } + }); + + if (!upstreamResponse.body) { + response.end(); + return; + } + + const reader = upstreamResponse.body.getReader(); + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + response.end(); + return; + } + + response.write(Buffer.from(value)); + } + } catch { + response.statusCode = 502; + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.end(JSON.stringify({ message: 'Backend proxy request failed.' })); + } +} + +module.exports = { + proxyRequest, +}; diff --git a/api/be1/[...path].js b/api/be1/[...path].js new file mode 100644 index 0000000..52840bb --- /dev/null +++ b/api/be1/[...path].js @@ -0,0 +1,8 @@ +const { proxyRequest } = require('../_proxy'); + +module.exports = function handler(request, response) { + return proxyRequest(request, response, { + envName: 'BE1_PROXY_TARGET_URL', + prefix: '/api/be1', + }); +}; diff --git a/api/be3/[...path].js b/api/be3/[...path].js new file mode 100644 index 0000000..20795ed --- /dev/null +++ b/api/be3/[...path].js @@ -0,0 +1,8 @@ +const { proxyRequest } = require('../_proxy'); + +module.exports = function handler(request, response) { + return proxyRequest(request, response, { + envName: 'BE3_PROXY_TARGET_URL', + prefix: '/api/be3', + }); +}; diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..5d27ba5 --- /dev/null +++ b/vercel.json @@ -0,0 +1,12 @@ +{ + "rewrites": [ + { + "source": "/be1/:path*", + "destination": "/api/be1/:path*" + }, + { + "source": "/be3/:path*", + "destination": "/api/be3/:path*" + } + ] +}