api.ts62 lines · main
1import { cookies, headers } from 'next/headers';
2
3import { apiOrigin, webOrigin } from './env';
4
5/**
6 * Server-side fetch against apps/api. Forwards the incoming request's cookies
7 * so Better Auth session cookies set on the api origin carry through, and
8 * pins the Origin header to the dashboard origin so apps/api's CSRF origin
9 * check accepts the request. Browser-side fetches set Origin natively;
10 * server-side fetches (RSC server actions) do not, which would otherwise
11 * produce a 403 `csrf_origin_rejected` on every mutation.
12 *
13 * Only call from server components / route handlers.
14 */
15export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
16 const cookieStore = await cookies();
17 const cookieHeader = cookieStore
18 .getAll()
19 .map((c) => `${c.name}=${c.value}`)
20 .join('; ');
21
22 const incoming = await headers();
23 const forwarded = incoming.get('x-forwarded-for') ?? incoming.get('x-real-ip');
24 // Prefer the inbound request's Origin (set on browser-originated requests
25 // that reach a Next.js server action); fall back to the configured web
26 // origin so direct RSC calls still pass the CSRF check.
27 const inboundOrigin = incoming.get('origin');
28 const originHeader = inboundOrigin && inboundOrigin.length > 0 ? inboundOrigin : webOrigin;
29
30 const res = await fetch(`${apiOrigin}${path}`, {
31 ...init,
32 headers: {
33 ...(init.headers ?? {}),
34 cookie: cookieHeader,
35 origin: originHeader,
36 ...(forwarded ? { 'x-forwarded-for': forwarded } : {}),
37 accept: 'application/json',
38 },
39 cache: 'no-store',
40 });
41 return res;
42}
43
44export async function apiJson<T>(path: string, init?: RequestInit): Promise<T> {
45 const res = await apiFetch(path, init);
46 if (!res.ok) {
47 const body = await res.text().catch(() => '');
48 throw new ApiError(res.status, body || res.statusText);
49 }
50 return (await res.json()) as T;
51}
52
53export class ApiError extends Error {
54 constructor(
55 readonly status: number,
56 message: string,
57 ) {
58 super(message);
59 this.name = 'ApiError';
60 }
61}
62