SPB Git

Repository Public

api.ts 7.8 KB 187 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 '@cr/shared';7import { confirmBrandingFromServer } from '@/stores/branding';89export const API_BASE = '/api/v1';1011export class ApiRequestError extends Error {12  constructor(13    readonly status: number,14    readonly code: string,15    message: string,16    readonly details?: unknown,17    readonly retryAfterSec?: number,18  ) {19    super(message);20    this.name = 'ApiRequestError';21  }22}2324let csrfToken: string | null = null;25let statusInflight: Promise<AuthStatus> | null = null;2627export function fetchAuthStatus(): Promise<AuthStatus> {28  statusInflight ??= (async () => {29    try {30      const res = await fetch(`${API_BASE}/auth/status`, { credentials: 'same-origin', cache: 'no-store' });31      if (!res.ok) throw await toError(res);32      const status = AuthStatus.parse(await res.json());33      csrfToken = status.csrfToken;34      confirmBrandingFromServer(status.branding); // the instance's product: wins over the cached value35      return status;36    } finally {37      statusInflight = null;38    }39  })();40  return statusInflight;41}4243async function toError(res: Response): Promise<ApiRequestError> {44  let code = `http_${res.status}`;45  let message = res.statusText || `Request failed (${res.status})`;46  let details: unknown;47  try {48    const parsed = ApiErrorSchema.safeParse(await res.json());49    if (parsed.success) ({ code, message, details } = parsed.data.error);50  } catch {51    /* non-JSON error body */52  }53  let retry: number | undefined;54  const d = details as { retryAfterSec?: unknown } | undefined;55  if (d && typeof d.retryAfterSec === 'number') retry = d.retryAfterSec;56  const h = Number(res.headers.get('Retry-After'));57  if (retry === undefined && Number.isFinite(h) && h > 0) retry = h;58  return new ApiRequestError(res.status, code, message, details, retry);59}6061export function isAuthPage(): boolean {62  return typeof location !== 'undefined' && /^\/(login|setup)\/?$/.test(location.pathname);63}64let redirecting = false;65/** Navigates to the login page at most once per page load (parallel 401s must not loop). */66export function redirectToLogin(): void {67  if (typeof location === 'undefined' || isAuthPage() || redirecting) return;68  redirecting = true;69  const next = location.pathname + location.search;70  location.assign(`/login/?next=${encodeURIComponent(next)}`);71}72/** Only same-origin absolute paths are accepted as a post-login destination. */73export function safeNext(raw: string | null): string {74  return raw && raw.startsWith('/') && !raw.startsWith('//') && !raw.startsWith('/login') && !raw.startsWith('/setup') ? raw : '/'; // `/` picks Home (phone) or the workspace (desktop)75}7677interface RequestOpts {78  signal?: AbortSignal;79  /** Do not navigate to /login/ on 401 (auth endpoints handle it themselves). */80  noAuthRedirect?: boolean;81}82const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);8384async function raw(method: string, path: string, body: unknown, opts: RequestOpts, retried = false): Promise<Response> {85  const headers: Record<string, string> = { Accept: 'application/json' };86  if (MUTATING.has(method)) {87    if (csrfToken === null) await fetchAuthStatus().catch(() => undefined);88    if (csrfToken) headers['X-CSRF-Token'] = csrfToken;89  }90  let payload: BodyInit | undefined;91  if (body instanceof Blob || body instanceof ArrayBuffer) {92    payload = body;93    headers['Content-Type'] = 'application/octet-stream';94  } else if (body !== undefined) {95    payload = JSON.stringify(body);96    headers['Content-Type'] = 'application/json';97  }98  const res = await fetch(API_BASE + path, { method, headers, body: payload, credentials: 'same-origin', cache: 'no-store', signal: opts.signal });99  if (res.ok) return res;100  const err = await toError(res);101  if (res.status === 403 && err.code.includes('csrf') && !retried) {102    csrfToken = null;103    await fetchAuthStatus().catch(() => undefined);104    return raw(method, path, body, opts, true);105  }106  if (res.status === 401 && !opts.noAuthRedirect && !path.startsWith('/auth/')) redirectToLogin();107  throw err;108}109110async function json<T>(method: string, path: string, body?: unknown, opts: RequestOpts = {}): Promise<T> {111  const res = await raw(method, path, body, opts);112  if (res.status === 204) return undefined as T;113  const text = await res.text();114  return (text ? JSON.parse(text) : undefined) as T;115}116117export const api = {118  get: <T>(path: string, opts?: RequestOpts) => json<T>('GET', path, undefined, opts),119  post: <T>(path: string, body?: unknown, opts?: RequestOpts) => json<T>('POST', path, body ?? {}, opts),120  put: <T>(path: string, body?: unknown, opts?: RequestOpts) => json<T>('PUT', path, body ?? {}, opts),121  patch: <T>(path: string, body?: unknown, opts?: RequestOpts) => json<T>('PATCH', path, body ?? {}, opts),122  delete: <T>(path: string, opts?: RequestOpts) => json<T>('DELETE', path, undefined, opts),123  /** Raw response (binary logs, downloads). */124  raw: (path: string, opts?: RequestOpts) => raw('GET', path, undefined, opts ?? {}),125  csrf: () => csrfToken,126  clearCsrf: () => {127    csrfToken = null;128  },129};130131/** Some list endpoints may be wrapped (`{items: [...]}`); accept both shapes. */132export function asList<T>(data: unknown): T[] {133  if (Array.isArray(data)) return data as T[];134  if (data && typeof data === 'object') {135    for (const v of Object.values(data)) if (Array.isArray(v)) return v as T[];136  }137  return [];138}139140export function qs(params: Record<string, string | number | boolean | null | undefined>): string {141  const sp = new URLSearchParams();142  for (const [k, v] of Object.entries(params)) if (v !== null && v !== undefined && v !== '') sp.set(k, String(v));143  const s = sp.toString();144  return s ? `?${s}` : '';145}146147/** WebSocket URL: same origin in production; NEXT_PUBLIC_DEV_WS_ORIGIN only exists in `next dev`. */148export function wsUrl(path: string): string {149  const dev = process.env.NEXT_PUBLIC_DEV_WS_ORIGIN;150  const origin = dev || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`;151  return `${origin}${API_BASE}${path}`;152}153154/** Upload with progress (XHR: fetch has no upload progress). Body is the raw file. */155export function uploadFile(projectId: string, destPath: string, file: Blob, onProgress?: (fraction: number) => void, overwrite = false): Promise<void> {156  return new Promise((resolve, reject) => {157    const xhr = new XMLHttpRequest();158    xhr.open('POST', `${API_BASE}/projects/${projectId}/upload${qs({ path: destPath, overwrite: overwrite ? 1 : undefined })}`);159    xhr.withCredentials = true;160    if (csrfToken) xhr.setRequestHeader('X-CSRF-Token', csrfToken);161    xhr.setRequestHeader('Content-Type', 'application/octet-stream');162    xhr.upload.onprogress = (e) => e.lengthComputable && onProgress?.(e.loaded / e.total);163    xhr.onload = () => {164      if (xhr.status >= 200 && xhr.status < 300) return resolve();165      if (xhr.status === 401) redirectToLogin();166      let message = `Upload failed (${xhr.status})`;167      let code = `http_${xhr.status}`;168      try {169        const p = ApiErrorSchema.safeParse(JSON.parse(xhr.responseText));170        if (p.success) ({ code, message } = p.data.error);171      } catch {172        /* keep generic message */173      }174      reject(new ApiRequestError(xhr.status, code, message));175    };176    xhr.onerror = () => reject(new ApiRequestError(0, 'network', 'Network error during upload'));177    xhr.send(file);178  });179}180export const downloadUrl = (projectId: string, path: string): string => `${API_BASE}/projects/${projectId}/download${qs({ path })}`;181182export function errorMessage(e: unknown): string {183  if (e instanceof ApiRequestError) return e.message;184  if (e instanceof Error) return e.message;185  return 'Unexpected error';186}187
Files main
Menu

Theme