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);
}