/** * mizan/client * * HTTP client factories for Django backends. * Framework-agnostic - works with vanilla JS, React, Vue, etc. * * ## Quick Start * * ### Client-Side (CSR) * ```ts * import { createDjangoCSRClient, Auth } from 'mizan/client' * * // Session-based (cookies + CSRF) * const client = createDjangoCSRClient(Auth.SESSION) * * // JWT-based (Bearer token) * const client = createDjangoCSRClient(Auth.JWT, { getAccessToken }) * * const user = await client.json('GET', '/api/accounts/me/') * ``` * * ### Server-Side (SSR) * ```ts * import { createDjangoSSRClient } from 'mizan/client' * * const client = createDjangoSSRClient({ * cookies: await cookies() // Next.js cookies() * }) * * const user = await client.json('GET', '/api/accounts/me/') * ``` * * ## React Hooks * * For React, import from `/react`: * ```tsx * import { useDjangoCSRClient, Auth } from 'mizan/client/react' * * const client = useDjangoCSRClient(Auth.SESSION) * ``` */ // ============================================================================= // Types // ============================================================================= /** * Authentication strategy for client-side requests. */ export enum Auth { /** Session cookies with CSRF token */ SESSION = 'session', /** JWT Bearer token */ JWT = 'jwt', } /** * Cookie getter interface (matches Next.js cookies() return type). */ export interface CookieGetter { get(name: string): { name: string; value: string } | undefined getAll(): { name: string; value: string }[] } /** * Cookie configuration for SSR requests. * Can be either a cookie getter (like Next.js cookies()) or pre-extracted values. */ export type SSRCookies = CookieGetter | { /** CSRF token value */ csrf: string /** Full cookie header string */ cookieHeader: string } /** * The core HTTP client interface for Django requests. */ export interface DjangoHTTPClient { /** * Make an HTTP request, returning the raw Response. */ request(method: string, path: string, data?: unknown, headers?: Record): Promise /** * Make an HTTP request, parsing the response as JSON. * @throws {HttpError} When response is not ok */ json(method: string, path: string, data?: unknown, headers?: Record): Promise } /** * Configuration for CSR client. */ export interface CSRClientConfig { /** Base URL for the Django backend */ baseUrl?: string | (() => string) } /** * Configuration for JWT-authenticated CSR client. */ export interface JWTClientConfig extends CSRClientConfig { /** Async function that returns the current access token */ getAccessToken: () => Promise } /** * Configuration for SSR client. */ export interface SSRClientConfig { /** Cookies for authentication forwarding */ cookies: SSRCookies /** Internal backend URL override (defaults to http://${INTERNAL_BACKEND_HOSTNAME}:8000) */ baseUrl?: string } // ============================================================================= // Errors // ============================================================================= /** * Details about an HTTP error. */ export interface HttpErrorDetails { status: number statusText: string url: string bodyJson?: unknown bodySnippet?: string bodyIsHtml?: boolean } /** * Error thrown when an HTTP request fails. */ export class HttpError extends Error { constructor( message: string, public readonly details: HttpErrorDetails ) { super(message) this.name = 'HttpError' } } // ============================================================================= // Internal Utilities // ============================================================================= import { getCSRFToken } from '../utils' interface RequestBuild { request: RequestInit hasBody: boolean } function buildRequest(method: string, data?: unknown, headers?: Record): RequestBuild { const isBodyMethod = !['GET', 'HEAD'].includes(method.toUpperCase()) const hasBody = isBodyMethod && data !== undefined const requestHeaders: Record = { 'Accept': 'application/json', ...headers, } let body: BodyInit | undefined if (hasBody) { if (data instanceof FormData) { body = data } else { body = JSON.stringify(data) requestHeaders['Content-Type'] = 'application/json' } } return { request: { method: method.toUpperCase(), headers: requestHeaders, body, credentials: 'include', }, hasBody, } } async function buildHttpError(resp: Response, url: URL | string): Promise { const urlStr = typeof url === 'string' ? url : url.toString() const details: HttpErrorDetails = { status: resp.status, statusText: resp.statusText, url: urlStr, } try { const contentType = resp.headers.get('content-type') ?? '' if (contentType.includes('application/json')) { details.bodyJson = await resp.clone().json() } else { const text = await resp.clone().text() details.bodyIsHtml = contentType.includes('text/html') details.bodySnippet = text.slice(0, 500) } } catch { // Ignore body parsing errors } return new HttpError(`Request failed: ${resp.status} ${resp.statusText}`, details) } // ============================================================================= // CSR Client Factory // ============================================================================= /** * Create a client-side HTTP client for Django. * * @param auth - Authentication strategy (Auth.SESSION or Auth.JWT) * @param config - Client configuration * @returns DjangoHTTPClient * * @example * // Session-based * const client = createDjangoCSRClient(Auth.SESSION) * * @example * // JWT-based * const client = createDjangoCSRClient(Auth.JWT, { * getAccessToken: async () => localStorage.getItem('token') * }) */ export function createDjangoCSRClient(auth: Auth.SESSION, config?: CSRClientConfig): DjangoHTTPClient export function createDjangoCSRClient(auth: Auth.JWT, config: JWTClientConfig): DjangoHTTPClient export function createDjangoCSRClient( auth: Auth, config?: CSRClientConfig | JWTClientConfig ): DjangoHTTPClient { if (!config?.baseUrl) { throw new Error( 'baseUrl is required. Pass it via config or use MizanProvider which provides it automatically.' ) } const getBaseUrl = () => typeof config.baseUrl === 'function' ? config.baseUrl() : config.baseUrl! const getHeaders = async (): Promise> => { if (auth === Auth.JWT) { const jwtConfig = config as JWTClientConfig const token = await jwtConfig.getAccessToken() if (token) { return { Authorization: `Bearer ${token}` } } return {} } // Session auth uses CSRF return { 'X-CSRFToken': getCSRFToken() ?? '' } } function resolveUrl(path: string): string { const base = getBaseUrl() // Absolute base URL — use URL constructor if (base.startsWith('http://') || base.startsWith('https://')) { return new URL(path, base).toString() } // Relative base — path is already usable by fetch in a browser return path } return { request: async (method, path, data?, headers?) => { const url = resolveUrl(path) const configHeaders = await getHeaders() const build = buildRequest(method, data, { ...configHeaders, ...headers }) return fetch(url, build.request) }, json: async (method: string, path: string, data?: unknown, headers?: Record): Promise => { const url = resolveUrl(path) const configHeaders = await getHeaders() const build = buildRequest(method, data, { ...configHeaders, ...headers }) const resp = await fetch(url, build.request) if (!resp.ok) { throw await buildHttpError(resp, url) } return resp.json() }, } } // ============================================================================= // Internal Backend URL Resolution // ============================================================================= function getInternalBackendUrl(override?: string): string { if (override) return override throw new Error( 'baseUrl is required for SSR client. Pass it via config.' ) } // ============================================================================= // SSR Client Factory // ============================================================================= /** * Check if cookies is a CookieGetter interface. */ function isCookieGetter(cookies: SSRCookies): cookies is CookieGetter { return typeof (cookies as CookieGetter).get === 'function' } /** * Extract CSRF token and cookie header from SSRCookies. */ function extractCookies(cookies: SSRCookies): { csrf: string; cookieHeader: string } { if (isCookieGetter(cookies)) { return { csrf: cookies.get('csrftoken')?.value ?? '', cookieHeader: cookies.getAll().map(c => `${c.name}=${c.value}`).join('; ') } } return cookies } /** * Create a server-side HTTP client for Django. * Used in SSR contexts (Next.js server components, server actions, etc.) * * @param config - SSR client configuration with cookies * @returns DjangoHTTPClient * * @example * // Next.js server component * import { cookies } from 'next/headers' * * const client = createDjangoSSRClient({ cookies: await cookies() }) */ // Re-export auth types for non-React usage export type { BaseUser, AuthDetails, AuthRoutes, JWTTokens, JWTConfig, JWTState, } from './types' // Re-export RouterAdapter for libraries that extend it export type { RouterAdapter } from './RouterContext' export function createDjangoSSRClient(config: SSRClientConfig): DjangoHTTPClient { const baseUrl = getInternalBackendUrl(config.baseUrl) const { csrf, cookieHeader } = extractCookies(config.cookies) return { request: async (method, path, data?, headers?) => { const url = new URL(path, baseUrl) const requestHeaders: Record = { 'Accept': 'application/json', 'X-CSRFToken': csrf, 'Cookie': cookieHeader, ...headers, } let body: BodyInit | undefined if (data && !['GET', 'HEAD'].includes(method.toUpperCase())) { if (data instanceof FormData) { body = data } else { body = JSON.stringify(data) requestHeaders['Content-Type'] = 'application/json' } } return fetch(url, { method: method.toUpperCase(), headers: requestHeaders, body, credentials: 'include', cache: 'no-store', }) }, json: async (method: string, path: string, data?: unknown, headers?: Record): Promise => { const url = new URL(path, baseUrl) const requestHeaders: Record = { 'Accept': 'application/json', 'X-CSRFToken': csrf, 'Cookie': cookieHeader, ...headers, } let body: BodyInit | undefined if (data && !['GET', 'HEAD'].includes(method.toUpperCase())) { if (data instanceof FormData) { body = data } else { body = JSON.stringify(data) requestHeaders['Content-Type'] = 'application/json' } } const resp = await fetch(url, { method: method.toUpperCase(), headers: requestHeaders, body, credentials: 'include', cache: 'no-store', }) if (!resp.ok) { throw await buildHttpError(resp, url) } const contentType = resp.headers.get('content-type') ?? '' if (!contentType.includes('application/json')) { throw new Error(`Expected JSON response but got ${contentType}`) } return resp.json() }, } } // ============================================================================= // SSR Session Initialization // ============================================================================= /** * Response from the session initialization endpoint. */ interface SessionInitResponse { csrfToken: string } /** * Ensure a Django session exists before making SSR requests. * * On first visit, the user has no cookies. This function pings Django to * establish a session and get a CSRF token, which can then be used for * subsequent hydration requests in the same SSR request chain. * * Note: Browser cookie forwarding is handled by Next.js middleware, not this * function. This function only ensures cookies exist for SSR data fetching. * * @param config - SSR client configuration with cookies * @returns Object with csrf token and cookie header for use in SSR requests * * @example * // In layout.tsx * const cookieStore = await cookies() * const session = await ensureDjangoSession({ cookies: cookieStore }) * const client = createDjangoSSRClient({ * cookies: { csrf: session.csrf, cookieHeader: session.cookieHeader } * }) */ export async function ensureDjangoSession(config: SSRClientConfig): Promise<{ csrf: string cookieHeader: string }> { const baseUrl = getInternalBackendUrl(config.baseUrl) const { csrf: existingCsrf, cookieHeader: existingCookies } = extractCookies(config.cookies) // If we already have a CSRF token, just return existing cookies if (existingCsrf) { return { csrf: existingCsrf, cookieHeader: existingCookies } } // No CSRF token - need to initialize session const url = new URL('/api/mizan/session/', baseUrl) const resp = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json', 'Cookie': existingCookies, }, credentials: 'include', cache: 'no-store', }) if (!resp.ok) { console.error('[mizan] Failed to initialize session:', resp.status, resp.statusText) return { csrf: '', cookieHeader: existingCookies } } // Extract CSRF token from response body const data: SessionInitResponse = await resp.json() // Extract Set-Cookie headers to build updated cookie string for SSR chain const setCookieHeaders = resp.headers.getSetCookie?.() ?? [] const newCookies = setCookieHeaders.map(c => c.split(';')[0]).join('; ') const combinedCookies = existingCookies ? `${existingCookies}; ${newCookies}` : newCookies return { csrf: data.csrfToken, cookieHeader: combinedCookies, } } // ============================================================================= // Server Function HTTP Call // ============================================================================= // Re-export error types from the canonical location export type { FunctionErrorResponse } from '../errors' import { DjangoError, type FunctionErrorResponse } from '../errors' /** * Success response from a server function */ export interface FunctionSuccessResponse { result: T invalidate?: Array }> } /** * Union type for server function responses */ export type FunctionResponse = FunctionSuccessResponse | FunctionErrorResponse