/** * Server-side proxy to https://etiya-d2l-api.hf.space. * * Browser → /api/proxy/ (NO auth header in browser request) * ↓ * Next.js server adds Authorization: Bearer ${HF_TOKEN} * ↓ * Forwards to D2L_API_URL/ * * Why a proxy and not NEXT_PUBLIC_HF_TOKEN? * - NEXT_PUBLIC_* values are inlined into the client bundle. * Anyone viewing the page source can read them. * - Server-only env vars (no NEXT_PUBLIC_) stay on the server. * The browser cannot read them. This is the only safe pattern * for Bearer tokens. */ import { NextRequest, NextResponse } from "next/server"; const API_URL = process.env.D2L_API_URL || "https://etiya-d2l-api.hf.space"; const TOKEN = process.env.HF_TOKEN; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; async function handle(req: NextRequest, params: { path: string[] }) { if (!TOKEN) { return NextResponse.json( { error: "HF_TOKEN env var not set on server. Copy .env.example to .env.local and add your token.", }, { status: 500 } ); } const path = (params.path ?? []).join("/"); const search = req.nextUrl.search; // includes leading "?" const upstreamUrl = `${API_URL}/${path}${search}`; const upstreamHeaders: Record = { Authorization: `Bearer ${TOKEN}`, // Forward client's Accept so SSE requests propagate; default to JSON. Accept: req.headers.get("accept") || "application/json", }; let body: BodyInit | null = null; if (req.method !== "GET" && req.method !== "HEAD") { const contentType = req.headers.get("content-type") || "application/json"; upstreamHeaders["Content-Type"] = contentType; body = await req.text(); } let resp: Response; try { resp = await fetch(upstreamUrl, { method: req.method, headers: upstreamHeaders, body, cache: "no-store", }); } catch (e: unknown) { return NextResponse.json( { error: "upstream_unreachable", message: e instanceof Error ? e.message : String(e), upstream: upstreamUrl, }, { status: 502 } ); } // Pass the upstream body through as a stream so SSE (text/event-stream) // reaches the browser unbuffered. Buffering with `await resp.text()` would // collapse streaming into a single chunk and defeat token-by-token render. const contentType = resp.headers.get("content-type") || "application/json"; const headers: Record = { "content-type": contentType, "cache-control": resp.headers.get("cache-control") ?? "no-cache, no-transform", }; if (contentType.includes("text/event-stream")) { headers["x-accel-buffering"] = "no"; headers["connection"] = "keep-alive"; } return new NextResponse(resp.body, { status: resp.status, headers, }); } // Next.js 15+ — `params` is a Promise that must be awaited inside the route. type RouteCtx = { params: Promise<{ path: string[] }> }; export async function GET(req: NextRequest, ctx: RouteCtx) { return handle(req, await ctx.params); } export async function POST(req: NextRequest, ctx: RouteCtx) { return handle(req, await ctx.params); } export async function PUT(req: NextRequest, ctx: RouteCtx) { return handle(req, await ctx.params); } export async function DELETE(req: NextRequest, ctx: RouteCtx) { return handle(req, await ctx.params); } export async function PATCH(req: NextRequest, ctx: RouteCtx) { return handle(req, await ctx.params); }