Files
homelab-app-template/frontend/src/app/api/backend/[...path]/route.ts
T

122 lines
4.2 KiB
TypeScript

/**
* Server-side API proxy for the backend. ⭐ THE SYSTEMIC AUTH FIX ⭐
*
* Do NOT replace this with a static `rewrites()` proxy in next.config.mjs. A
* static rewrite forwards requests as-is and CANNOT inject an Authorization
* header — that was the root cause of the "no token reaches the backend" bug
* (every browser fetch hit the backend unauthenticated → 401/500). This cost
* ~9 days on CannaManage; the template ships the fix so it never recurs.
*
* This Route Handler runs on the server, reads the NextAuth session via
* `auth()` (so the JWT never leaves the server), and forwards the request to
* `${BACKEND_URL}/api/v1/<path>` with `Authorization: Bearer <accessToken>`.
*
* Method- and content-agnostic: query string preserved; raw request body
* streamed unparsed (JSON + multipart uploads + binary all work); upstream
* response body streamed back verbatim (byte-exact downloads).
*
* Adjust the upstream path prefix (`/api/v1/`) if your backend differs.
*/
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { auth } from "@/lib/auth"
// Always run dynamically — this proxy depends on per-request auth + body.
export const dynamic = "force-dynamic"
const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8080"
// Hop-by-hop and host-specific headers that must not be forwarded upstream.
const STRIPPED_REQUEST_HEADERS = new Set([
"host",
"connection",
"content-length",
"transfer-encoding",
"accept-encoding",
])
// Headers that must not be copied from the upstream response back to the client.
const STRIPPED_RESPONSE_HEADERS = new Set([
"connection",
"transfer-encoding",
"content-encoding",
"content-length",
])
async function proxy(req: NextRequest, path: string[]): Promise<NextResponse> {
const session = await auth()
const accessToken = session?.accessToken
// Build the upstream URL: /api/backend/<path> → BACKEND_URL/api/v1/<path>
const search = req.nextUrl.search // includes leading "?" or ""
const upstreamUrl = `${BACKEND_URL}/api/v1/${path.join("/")}${search}`
// Clone the incoming headers, stripping hop-by-hop/host ones, then inject auth.
const headers = new Headers()
req.headers.forEach((value, key) => {
if (!STRIPPED_REQUEST_HEADERS.has(key.toLowerCase())) {
headers.set(key, value)
}
})
if (accessToken) {
headers.set("Authorization", `Bearer ${accessToken}`)
}
const method = req.method.toUpperCase()
const hasBody = method !== "GET" && method !== "HEAD"
try {
const upstream = await fetch(upstreamUrl, {
method,
headers,
// Stream the raw body through unparsed (works for JSON + multipart + binary).
body: hasBody ? req.body : undefined,
// Required by undici/Node when sending a streaming request body.
...(hasBody ? { duplex: "half" } : {}),
redirect: "manual",
cache: "no-store",
} as RequestInit)
// Copy upstream response headers, dropping ones that break a re-emitted body.
const responseHeaders = new Headers()
upstream.headers.forEach((value, key) => {
if (!STRIPPED_RESPONSE_HEADERS.has(key.toLowerCase())) {
responseHeaders.set(key, value)
}
})
// Stream the body straight back — byte-exact for downloads.
return new NextResponse(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
})
} catch {
return NextResponse.json(
{ code: "BACKEND_UNREACHABLE", message: "Unable to reach the API." },
{ status: 502 }
)
}
}
// Next.js 15: the second arg's `params` is a Promise.
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 PATCH(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)
}