Ctrl K
ComunidadGuías…

src/lib/api-proxy.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { NextRequest } from "next/server";

/**
 * The same-origin API proxy: everything the browser sends to `/api/v1/...` is streamed
 * through to the API container and its response streamed back.
 *
 * This is what makes a prebuilt web image work on any domain with no rebuild. The
 * browser only ever talks to the origin that served the page (`lib/api-base.ts`), so
 * there is no cross-site request to configure CORS for, no cross-site cookie to lose to
 * `SameSite=Lax`, and no API address baked into the client bundle. The one address that
 * does have to be known - the API container's - is read from `process.env` *per request*
 * here, which is why this is a route handler rather than a `rewrites()` entry: rewrites
 * are serialized into the build output and a prebuilt image would carry whatever the
 * builder happened to have set.
 *
 * See `DECISIONS.md`, "The web app proxies `/api/v1` to the API, and that is the
 * shipped topology".
 */

const DEFAULT_INTERNAL_URL = "http://api:8000";
const DEFAULT_DEV_INTERNAL_URL = "http://localhost:8000";

// Per RFC 9110 these describe a single transport connection, so forwarding them onto the
// next one is meaningless at best and a framing bug at worst - `transfer-encoding` in
// particular, since the hop this proxy opens does its own chunking. `trailer` is the
// spelling in the RFC; `trailers` is the one the older RFC 2616 list used and is still
// sent by some clients.
const HOP_BY_HOP_HEADERS = new Set([
  "connection",
  "keep-alive",
  "proxy-authenticate",
  "proxy-authorization",
  "te",
  "trailer",
  "trailers",
  "transfer-encoding",
  "upgrade",
]);

// Dropped from the *request* on top of the hop-by-hop set.
const REQUEST_ONLY_DROPS = new Set([
  // Names the origin the browser asked for, which is not the one being dialled. Left in
  // place it would reach the API as its `Host`, and any absolute URL the API builds from
  // it - or any host-based routing in front of it - would be wrong.
  "host",
  // `fetch` recomputes framing for the hop it opens. A forwarded length is either
  // redundant or, once the body is a stream, a lie.
  "content-length",
  // Never forwarded verbatim: `fetch` negotiates its own encoding for the hop it opens
  // and decodes the reply transparently, so the browser's list is the wrong one to send -
  // advertising a codec `fetch` cannot decode would leave a compressed body paired with a
  // `content-encoding` this proxy then has to strip (see `downstreamHeaders`), which is
  // corruption rather than a wrong header. `fetch` substitutes its own `accept-encoding`.
  "accept-encoding",
  // A `100-continue` negotiation with *this* server, which Node has already answered by
  // the time a handler runs. Forwarded, it reaches `fetch` as an unsupported header and
  // the whole request fails with `UND_ERR_NOT_SUPPORTED` - a 502 for every large upload
  // from curl or any other client that asks first. Browsers never send it, which is
  // exactly why this would have gone unnoticed.
  "expect",
]);

/**
 * The API container's address, read at request time.
 *
 * `API_INTERNAL_URL` is an *origin* - `http://api:8000`, no `/api/v1` - because the
 * request's own path is appended to it unchanged. The default is the compose service
 * name, so the shipped bundle needs no configuration at all; under `next dev` it is
 * `localhost:8000`, where the API's own compose file publishes it.
 */
export function resolveApiInternalUrl(
  env: { API_INTERNAL_URL?: string; NODE_ENV?: string } = process.env,
): string {
  const configured = env.API_INTERNAL_URL?.trim();
  if (configured) return configured.replace(/\/+$/, "");
  return env.NODE_ENV === "production"
    ? DEFAULT_INTERNAL_URL
    : DEFAULT_DEV_INTERNAL_URL;
}

// Everything named in `Connection` is hop-by-hop too, by definition - that header is how
// a peer declares its own additions to the list.
function connectionScopedHeaders(headers: Headers): Set<string> {
  const declared = headers.get("connection");
  if (!declared) return new Set();
  return new Set(
    declared
      .split(",")
      .map((name) => name.trim().toLowerCase())
      .filter(Boolean),
  );
}

function upstreamHeaders(request: NextRequest): Headers {
  const perConnection = connectionScopedHeaders(request.headers);
  const headers = new Headers();

  for (const [name, value] of request.headers) {
    if (HOP_BY_HOP_HEADERS.has(name)) continue;
    if (REQUEST_ONLY_DROPS.has(name)) continue;
    if (perConnection.has(name)) continue;
    headers.set(name, value);
  }

  // The forwarding chain the API's `client_ip()` walks to key its ten per-IP rate limits.
  // It arrives already assembled: Next fills `x-forwarded-for` from the socket peer and
  // `x-forwarded-proto` from the connection when they are absent, and leaves whatever a
  // proxy in front set when they are not - so the loop above has copied both across, and
  // that is the whole job. It is not optional. Drop them and every caller reaches the API
  // as this one container, collapsing all ten buckets into one, which is exactly the
  // failure the API's `TRUSTED_PROXY_IPS` exists to prevent. That variable is still what
  // decides whether the API believes the chain, and it has to name *this* container (the
  // API's peer), not the operator's own proxy, which the API never talks to directly -
  // but only where something that *appends* `X-Forwarded-For` really fronts this
  // container. Nothing is appended here, so on a directly exposed one the caller supplies
  // the whole chain and naming this container is what makes all ten buckets forgeable;
  // omitting it costs one shared bucket, which is the safe direction there.
  // `DECISIONS.md` and `.env.example` carry the long version.
  //
  // `x-forwarded-for` has no fallback here because there is nothing to fall back to: a
  // route handler cannot see the socket peer (`NextRequest.ip` was removed in Next 15),
  // so Next's own normalization is the only source for it. Proto and host can be
  // reconstructed from the request URL, which is what the two lines below do for a caller
  // that reached this function without them - a unit test today.
  if (!headers.has("x-forwarded-proto")) {
    headers.set("x-forwarded-proto", request.nextUrl.protocol.replace(":", ""));
  }
  if (!headers.has("x-forwarded-host")) {
    headers.set("x-forwarded-host", request.nextUrl.host);
  }

  return headers;
}

// Starlette builds a redirect's `Location` from the `Host` it was dialled on, which is
// the internal address: passed through, it would send the browser somewhere it cannot
// reach and tell it the private API host on the way. One that names that origin is cut
// to its path, which the browser resolves against this one; any other is the API's to
// mean and goes through untouched. Resolved against `target` so a relative or
// scheme-relative value is judged by where it really points.
function browserFacingLocation(location: string, target: string): string {
  let resolved: URL;
  try {
    resolved = new URL(location, target);
  } catch {
    return location;
  }
  if (resolved.origin !== new URL(target).origin) return location;
  return `${resolved.pathname}${resolved.search}${resolved.hash}`;
}

function downstreamHeaders(upstream: Response, target: string): Headers {
  const perConnection = connectionScopedHeaders(upstream.headers);
  // `fetch` decodes a compressed body transparently but leaves the header describing it
  // in place, so forwarding the pair would tell the browser to decode plain bytes - and
  // `content-length` with it, being the compressed length of a body that no longer is.
  // This is the common path, not a defensive one: `fetch` substitutes its own
  // `accept-encoding: gzip, deflate` for the one dropped above, so any API that
  // compresses at all comes back through here.
  const decoded = upstream.headers.has("content-encoding");
  const headers = new Headers();

  for (const [name, value] of upstream.headers) {
    if (HOP_BY_HOP_HEADERS.has(name)) continue;
    if (perConnection.has(name)) continue;
    if (decoded && (name === "content-encoding" || name === "content-length"))
      continue;
    // Merged into one comma-joined value by this loop, which is wrong for cookies -
    // `Expires` contains a comma. Re-added individually below.
    if (name === "set-cookie") continue;
    headers.set(
      name,
      name === "location" ? browserFacingLocation(value, target) : value,
    );
  }

  // The refresh-token cookie is the reason this proxy has to exist as more than a URL
  // rewrite: it is httpOnly, and it has to reach the browser as if the API had set it.
  for (const cookie of upstream.headers.getSetCookie()) {
    headers.append("set-cookie", cookie);
  }

  return headers;
}

/**
 * Streams one request through to the API and its response back.
 *
 * Nothing is buffered in either direction: every upload, a logbook archive included,
 * goes up as a stream and the export archive comes down as one. That holds because
 * `src/proxy.ts`'s matcher excludes `api/v1/` - a request that reaches middleware has its
 * body cloned into memory first, capped at Next's 10 MB default, and silently truncated
 * past it.
 *
 * Exported for `route.ts`, which binds it to every method. Route files may only export
 * handlers and segment config, so the wiring lives there and the logic lives here.
 */
export async function proxyToApi(request: NextRequest): Promise<Response> {
  // The raw URL rather than the resolved `params`: catch-all params arrive
  // percent-decoded, so re-joining them would mangle any segment containing an encoded
  // slash or space on the way back out.
  const { pathname, search } = new URL(request.url);
  const base = resolveApiInternalUrl();
  const target = `${base}${pathname}${search}`;

  // GET and HEAD carry no body; anything else streams whatever the browser sent.
  const body =
    request.method === "GET" || request.method === "HEAD" ? null : request.body;

  let upstream: Response;
  try {
    upstream = await fetch(target, {
      method: request.method,
      headers: upstreamHeaders(request),
      body,
      // `fetch` refuses a stream body without it. Not in the DOM lib's `RequestInit`,
      // which is what the cast below is for.
      ...(body ? { duplex: "half" } : {}),
      // A redirect is the API's answer to the browser, not an instruction to this proxy.
      redirect: "manual",
      // Next patches `fetch` with its own caching layer; an API proxy must never serve a
      // second caller from the first one's response.
      cache: "no-store",
      // A closed browser connection cancels the upstream request rather than leaving it
      // to run to completion against the API.
      signal: request.signal,
    } as RequestInit);
  } catch (error) {
    // The API being down, unresolvable, or still starting. Shaped like FastAPI's own
    // error body so `getApiErrorMessage` finds a message rather than its generic
    // fallback, and logged with the route so the cause is one line away.
    //
    // The path only, never the query string: `?token=...` on the two magic-link precheck
    // routes (`auth/email/verify/check`, `user/email-change/verify/check`) is a live,
    // single-use sign-in credential, and this branch is exactly the case where the check
    // never reached the API - so the token is still valid when it lands in the log. The
    // API refuses to log those for the same reason.
    console.error(
      `[api-proxy] ${request.method} ${base}${pathname} failed:`,
      error,
    );
    return Response.json(
      { detail: "The API is unreachable. Check that it is running." },
      { status: 502 },
    );
  }

  // A body is forbidden on these, and constructing a `Response` with one throws.
  const bodyless =
    request.method === "HEAD" ||
    upstream.status === 204 ||
    upstream.status === 205 ||
    upstream.status === 304;

  return new Response(bodyless ? null : upstream.body, {
    status: upstream.status,
    statusText: upstream.statusText,
    headers: downstreamHeaders(upstream, target),
  });
}