Spaces:
Paused
Paused
File size: 5,286 Bytes
8c1b9fe | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 | // Server-side reverse proxy: forwards /api/* to the Auralynq backend and injects
// the bearer token from a server-only env var, so the browser never holds the API
// key (ADR-0012). Streams responses (SSE token streaming works transparently).
//
// Failover (ADR-0020): when AURALYNQ_API_FALLBACK is set, the request is tried
// against the local/primary backend first and replayed against the remote
// fallback if the primary is unreachable or returns a gateway-class status. The
// served upstream is reported in the `x-auralynq-upstream` response header.
import { NextRequest } from "next/server";
import { Agent } from "undici";
import { resolveUpstreams, shouldFailover, type Upstream } from "@/lib/failover";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const API_KEY = process.env.AURALYNQ_SERVE__API_KEY || "";
const PRIMARY = process.env.AURALYNQ_API_INTERNAL || "http://api:8000";
const FALLBACK = process.env.AURALYNQ_API_FALLBACK || "";
const FALLBACK_INSECURE = process.env.AURALYNQ_API_FALLBACK_INSECURE_TLS === "1";
// Bound time-to-first-byte for the primary so a hung local backend still fails
// over. Generous by default: a cold LLM first token can take several seconds, and
// a dead backend refuses the connection instantly regardless of this value.
const PRIMARY_TIMEOUT_MS = Number(process.env.AURALYNQ_API_PRIMARY_TIMEOUT_MS || "12000");
const UPSTREAMS = resolveUpstreams(PRIMARY, FALLBACK, FALLBACK_INSECURE);
// Dispatcher that skips TLS verification — used only for an insecure fallback
// (the server's self-signed cert). The primary never uses it.
const insecureAgent = new Agent({ connect: { rejectUnauthorized: false } });
type ProxyBody = ArrayBuffer | ReadableStream<Uint8Array> | null;
async function attempt(
u: Upstream,
rel: string,
method: string,
headers: Headers,
body: ProxyBody,
timeoutMs: number,
): Promise<Response> {
const init: RequestInit & { duplex?: "half"; dispatcher?: Agent } = {
method,
headers,
redirect: "manual",
};
if (body !== null) {
init.body = body as BodyInit;
// A streamed request body (no-fallback path) needs half-duplex; a buffered
// ArrayBuffer does not.
if (typeof (body as ReadableStream).getReader === "function") init.duplex = "half";
}
if (u.insecure) init.dispatcher = insecureAgent;
const target = `${u.base}/${rel}`;
if (timeoutMs > 0) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
init.signal = ac.signal;
try {
// Resolves once response headers arrive; the body then streams freely
// because we clear the timer here.
return await fetch(target, init);
} finally {
clearTimeout(timer);
}
}
return fetch(target, init);
}
async function proxy(req: NextRequest, path: string[]): Promise<Response> {
const search = req.nextUrl.search || "";
const rel = `${path.join("/")}${search}`;
const baseHeaders = new Headers();
for (const [k, v] of req.headers) {
const key = k.toLowerCase();
if (["host", "connection", "content-length", "accept-encoding"].includes(key)) continue;
baseHeaders.set(k, v);
}
if (API_KEY) baseHeaders.set("Authorization", `Bearer ${API_KEY}`);
const method = req.method.toUpperCase();
const hasBody = !["GET", "HEAD"].includes(method);
const hasFallback = UPSTREAMS.length > 1;
// With a fallback we must be able to replay the body across attempts, so buffer
// it; with a single upstream, stream it through untouched (no extra memory).
let body: ProxyBody = null;
if (hasBody) body = hasFallback ? await req.arrayBuffer() : req.body;
let lastErr: unknown = null;
for (let i = 0; i < UPSTREAMS.length; i++) {
const u = UPSTREAMS[i];
const isLast = i === UPSTREAMS.length - 1;
const timeoutMs = isLast ? 0 : PRIMARY_TIMEOUT_MS; // only bound when we can fail over
try {
const r = await attempt(u, rel, method, new Headers(baseHeaders), body, timeoutMs);
if (!isLast && shouldFailover(r.status)) {
lastErr = new Error(`upstream ${u.label} returned ${r.status}`);
continue;
}
const respHeaders = new Headers(r.headers);
respHeaders.delete("content-encoding");
respHeaders.delete("content-length");
respHeaders.set("x-auralynq-upstream", u.label);
return new Response(r.body, { status: r.status, headers: respHeaders });
} catch (e) {
lastErr = e;
if (isLast) break;
}
}
return new Response(
JSON.stringify({ error: "bad_gateway", detail: String(lastErr) }),
{ status: 502, headers: { "content-type": "application/json", "x-auralynq-upstream": "none" } },
);
}
type Ctx = { params: Promise<{ path: string[] }> };
export async function GET(req: NextRequest, ctx: Ctx) {
return proxy(req, (await ctx.params).path);
}
export async function POST(req: NextRequest, ctx: Ctx) {
return proxy(req, (await ctx.params).path);
}
export async function PUT(req: NextRequest, ctx: Ctx) {
return proxy(req, (await ctx.params).path);
}
export async function DELETE(req: NextRequest, ctx: Ctx) {
return proxy(req, (await ctx.params).path);
}
export async function PATCH(req: NextRequest, ctx: Ctx) {
return proxy(req, (await ctx.params).path);
}
|