SPB Git

Repository Public

api.ts 6.2 KB 158 lines typescript

1/**2 * Typed client for the same-origin browser API (/api/v1). Authentication is an HttpOnly3 * cookie; the CSRF token lives in memory only (never localStorage/sessionStorage).4 */5import '@/lib/zod-config';6import { ApiError as ApiErrorSchema, AuthStatus } from '@/lib/dto-auth';78export const API_BASE = '/api/v1';910export class ApiRequestError extends Error {11  constructor(12    readonly status: number,13    readonly code: string,14    message: string,15    readonly details?: unknown,16    readonly retryAfterSec?: number,17  ) {18    super(message);19    this.name = 'ApiRequestError';20  }21}2223let csrfToken: string | null = null;24let statusInflight: Promise<AuthStatus> | null = null;2526export function fetchAuthStatus(): Promise<AuthStatus> {27  statusInflight ??= (async () => {28    try {29      const res = await fetch(`${API_BASE}/auth/status`, { credentials: 'same-origin', cache: 'no-store' });30      if (!res.ok) throw await toError(res);31      const status = AuthStatus.parse(await res.json());32      csrfToken = status.csrfToken;33      return status;34    } finally {35      statusInflight = null;36    }37  })();38  return statusInflight;39}4041async function toError(res: Response): Promise<ApiRequestError> {42  let code = `http_${res.status}`;43  let message = res.statusText || `Request failed (${res.status})`;44  let details: unknown;45  try {46    const parsed = ApiErrorSchema.safeParse(await res.json());47    if (parsed.success) ({ code, message, details } = parsed.data.error);48  } catch {49    /* non-JSON error body */50  }51  let retry: number | undefined;52  const d = details as { retryAfterSec?: unknown } | undefined;53  if (d && typeof d.retryAfterSec === 'number') retry = d.retryAfterSec;54  const h = Number(res.headers.get('Retry-After'));55  if (retry === undefined && Number.isFinite(h) && h > 0) retry = h;56  return new ApiRequestError(res.status, code, message, details, retry);57}5859export function isAuthPage(): boolean {60  return typeof location !== 'undefined' && /^\/(login|setup)\/?$/.test(location.pathname);61}62let redirecting = false;63/** Navigates to the login page at most once per page load (parallel 401s must not loop). */64export function redirectToLogin(): void {65  if (typeof location === 'undefined' || isAuthPage() || redirecting) return;66  redirecting = true;67  const next = location.pathname + location.search;68  location.assign(`/login/?next=${encodeURIComponent(next)}`);69}70/** Only same-origin absolute paths are accepted as a post-login destination. */71export function safeNext(raw: string | null): string {72  return raw && raw.startsWith('/') && !raw.startsWith('//') && !raw.startsWith('/login') && !raw.startsWith('/setup') ? raw : '/'; // `/` redirects to Home73}7475interface RequestOpts {76  signal?: AbortSignal;77  /** Do not navigate to /login/ on 401 (auth endpoints handle it themselves). */78  noAuthRedirect?: boolean;79}80const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);8182async function raw(method: string, path: string, body: unknown, opts: RequestOpts, retried = false): Promise<Response> {83  const headers: Record<string, string> = { Accept: 'application/json' };84  if (MUTATING.has(method)) {85    if (csrfToken === null) await fetchAuthStatus().catch(() => undefined);86    if (csrfToken) headers['X-CSRF-Token'] = csrfToken;87  }88  let payload: BodyInit | undefined;89  if (body instanceof Blob || body instanceof ArrayBuffer) {90    payload = body;91    headers['Content-Type'] = 'application/octet-stream';92  } else if (body !== undefined) {93    payload = JSON.stringify(body);94    headers['Content-Type'] = 'application/json';95  }96  const res = await fetch(API_BASE + path, { method, headers, body: payload, credentials: 'same-origin', cache: 'no-store', signal: opts.signal });97  if (res.ok) return res;98  const err = await toError(res);99  if (res.status === 403 && err.code.includes('csrf') && !retried) {100    csrfToken = null;101    await fetchAuthStatus().catch(() => undefined);102    return raw(method, path, body, opts, true);103  }104  if (res.status === 401 && !opts.noAuthRedirect && !path.startsWith('/auth/')) redirectToLogin();105  throw err;106}107108async function json<T>(method: string, path: string, body?: unknown, opts: RequestOpts = {}): Promise<T> {109  const res = await raw(method, path, body, opts);110  if (res.status === 204) return undefined as T;111  const text = await res.text();112  return (text ? JSON.parse(text) : undefined) as T;113}114115export const api = {116  get: <T>(path: string, opts?: RequestOpts) => json<T>('GET', path, undefined, opts),117  post: <T>(path: string, body?: unknown, opts?: RequestOpts) => json<T>('POST', path, body ?? {}, opts),118  put: <T>(path: string, body?: unknown, opts?: RequestOpts) => json<T>('PUT', path, body ?? {}, opts),119  patch: <T>(path: string, body?: unknown, opts?: RequestOpts) => json<T>('PATCH', path, body ?? {}, opts),120  delete: <T>(path: string, opts?: RequestOpts) => json<T>('DELETE', path, undefined, opts),121  csrf: () => csrfToken,122  clearCsrf: () => {123    csrfToken = null;124  },125};126127/** Some list endpoints may be wrapped (`{items: [...]}`); accept both shapes. */128export function asList<T>(data: unknown): T[] {129  if (Array.isArray(data)) return data as T[];130  if (data && typeof data === 'object') {131    for (const v of Object.values(data)) if (Array.isArray(v)) return v as T[];132  }133  return [];134}135136export function qs(params: Record<string, string | number | boolean | null | undefined>): string {137  const sp = new URLSearchParams();138  for (const [k, v] of Object.entries(params)) if (v !== null && v !== undefined && v !== '') sp.set(k, String(v));139  const s = sp.toString();140  return s ? `?${s}` : '';141}142143/** WebSocket URL: same origin in production; NEXT_PUBLIC_DEV_WS_ORIGIN only exists in `next dev`. */144export function wsUrl(path: string): string {145  const dev = process.env.NEXT_PUBLIC_DEV_WS_ORIGIN;146  const origin = dev || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`;147  return `${origin}${API_BASE}${path}`;148}149150/** Download URL of a content-addressed artifact (served as an attachment, nosniff). */151export const artifactUrl = (sha256: string, name?: string | null): string => `${API_BASE}/artifacts/${encodeURIComponent(sha256)}${qs({ name })}`;152153export function errorMessage(e: unknown): string {154  if (e instanceof ApiRequestError) return e.message;155  if (e instanceof Error) return e.message;156  return 'Unexpected error';157}158
Files main
Menu

Theme