'use client' /** * mizan React Context * * Provides server function calls via HTTP (default) or WebSocket RPC (opt-in). * This is the core React integration for Django server functions. * * Transport Model: * - HTTP-first: Functions use HTTP by default (transport='http' or undefined) * - WebSocket opt-in: Functions with transport='websocket' use WebSocket RPC * when connected, falling back to HTTP when disconnected * * Two layers: * 1. MizanProvider (this file) - Generic provider with name-based API * - Libraries like Allauth use this: useMizan(), useContext('current_user') * * 2. Generated DjangoContext (in @/api) - Typed wrapper around MizanProvider * - Product code uses this: useCurrentUser(), useUpdateProfile() * * The generated code wraps MizanProvider and adds type-safe hooks. */ import { createContext, useContext as useReactContext, useEffect, useMemo, useRef, useState, useCallback, type ReactNode, } from 'react' import { ChannelConnection, RPCError } from 'mizan/channels' import { createDjangoCSRClient, Auth, type FunctionResponse, } from 'mizan/client' import { useJWT } from './jwt' import { DjangoError, type ErrorCode, type FunctionErrorResponse } from './errors' // ============================================================================ // Utilities // ============================================================================ function getCSRFToken(): string | null { if (typeof document === 'undefined') return null const match = document.cookie.match(/csrftoken=([^;]+)/) return match?.[1] ?? null } // ============================================================================ // Types // ============================================================================ export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' /** Push message received from server */ export interface PushMessage { topic: string data: T } /** Listener for push messages */ export type PushListener = (message: PushMessage) => void /** Context data store - maps context names to their data */ export type ContextStore = Record /** Hydration data for SSR - maps context names to their initial data */ export type MizanHydration = Record /** Transport mode for server function calls */ export type Transport = 'http' | 'websocket' export interface MizanContextValue { /** * Call a server function by name. * * Transport behavior: * - 'http' (default): Always use HTTP POST /api/mizan/call/ * - 'websocket': Use WebSocket RPC when connected, HTTP fallback when not * * @param functionName - The server function name (e.g., 'echo', 'update_profile') * @param input - Optional input data for the function * @param transport - Transport mode ('http' or 'websocket', defaults to 'http') */ call: ( functionName: string, input?: TInput, transport?: Transport ) => Promise /** * Get cached context data by name. * Returns undefined if the context hasn't been loaded yet. */ getContext: (name: string) => T | undefined /** * Refresh a specific context by name. * Fetches fresh data from the server and updates the cache. */ refreshContext: (name: string) => Promise /** * Refresh all registered contexts. */ refreshAllContexts: () => Promise /** * Current WebSocket connection status. */ status: ConnectionStatus /** * Whether WebSocket RPC is available. */ isRPCAvailable: boolean /** * Subscribe to push messages for a topic. * Returns an unsubscribe function. */ onPush: (topic: string, listener: PushListener) => () => void /** * Subscribe to context changes. * Returns an unsubscribe function. */ onContextChange: (name: string, listener: (data: unknown) => void) => () => void /** * Promise that resolves when the session is initialized (CSRF cookie set). * Await this before making HTTP calls in contexts where timing matters * (e.g., calling a server function immediately on mount). */ whenReady: Promise /** * Invalidate a named context, triggering a refetch. * Only refetches if the context is currently mounted (has a registered provider). * No-op if the context is not mounted. */ invalidateContext: (name: string) => Promise /** * Invalidate specific functions within their contexts. * Groups by context and calls invalidateContext per group. */ invalidateFunctions: (names: string[]) => Promise /** * Register a named context provider for invalidation support. * Called by generated context providers on mount. * Returns an unregister function (call on unmount). */ registerContextProvider: ( name: string, refetch: () => Promise, ) => () => void /** * Base URL for HTTP calls (for use by generated context providers). */ baseUrl: string /** * Set context data directly without triggering a network request. * Used by generated providers that fetch bundled responses. */ setContextData: (name: string, data: unknown) => void /** * Make an authenticated HTTP request. * Handles JWT Bearer or session cookie auth automatically. * Waits for session init before making the request. */ request: (method: string, path: string, data?: unknown) => Promise } export interface MizanProviderProps { children: ReactNode /** * Initial hydration data for contexts (from SSR). * Keys are context names, values are the data. */ hydration?: MizanHydration /** * List of context names to auto-fetch if not in hydration. * These will be fetched on mount. */ contexts?: string[] /** * Base URL for HTTP fallback calls. * @default '/api/mizan' */ baseUrl?: string /** * WebSocket URL for RPC calls. * @default '/ws/' */ wsUrl?: string /** * Whether to connect WebSocket automatically. * @default true */ autoConnect?: boolean /** * WebSocket reconnection options. */ reconnect?: boolean reconnectDelay?: number maxReconnectAttempts?: number /** * Custom connection instance (for testing). */ connection?: ChannelConnection } // ============================================================================ // Context // ============================================================================ const MizanContextInternal = createContext(null) // ============================================================================ // Provider // ============================================================================ export function MizanProvider({ children, hydration, contexts: contextNames = [], baseUrl = '/api/mizan', wsUrl = '/ws/', autoConnect = true, reconnect = true, reconnectDelay = 1000, maxReconnectAttempts = 10, connection: providedConnection, }: MizanProviderProps) { const connectionRef = useRef(null) // Push listeners: Map> const pushListenersRef = useRef>>(new Map()) // Context change listeners: Map> const contextListenersRef = useRef void>>>(new Map()) // Context data store const [contextStore, setContextStore] = useState(() => { // Initialize from hydration if provided return hydration ?? {} }) // Check if JWT is available - use JWT auth if so, otherwise session auth const jwt = useJWT() const hasJWT = jwt !== null && jwt.tokens !== null const [sessionReady, setSessionReady] = useState(false) // Promise that resolves when session is initialized. // Exposed via context so any code that needs to wait for CSRF can await it. const sessionRef = useRef<{ promise: Promise; resolve: () => void } | null>(null) if (!sessionRef.current) { let resolve!: () => void const promise = new Promise(r => { resolve = r }) sessionRef.current = { promise, resolve } } // Create HTTP client with appropriate auth method const httpClient = useMemo(() => { if (jwt?.getAccessToken) { return createDjangoCSRClient(Auth.JWT, { baseUrl, getAccessToken: jwt.getAccessToken, }) } return createDjangoCSRClient(Auth.SESSION, { baseUrl }) }, [hasJWT, jwt?.getAccessToken, baseUrl]) // Create or use provided connection if (!connectionRef.current) { connectionRef.current = providedConnection ?? new ChannelConnection({ url: wsUrl, reconnect, reconnectDelay, maxReconnectAttempts, }) } const connection = connectionRef.current // Track connection status const [status, setStatus] = useState( connection.status as ConnectionStatus ) // The core call function: HTTP-first, WebSocket opt-in const call = useCallback( async ( functionName: string, input?: TInput, transport: Transport = 'http' ): Promise => { // Only attempt WebSocket if explicitly requested AND connected if (transport === 'websocket' && connection.status === 'connected') { try { return await connection.rpc(functionName, input as TInput) } catch (e) { // If it's an RPC error (function error), re-throw as DjangoError if (e instanceof RPCError) { throw new DjangoError({ error: true, code: e.code as ErrorCode, message: e.message, details: e.details, }) } // Connection error - fall through to HTTP console.warn( `[mizan] WebSocket RPC failed for '${functionName}', falling back to HTTP:`, e ) } } // Wait for session init (CSRF cookie) before making HTTP requests await sessionRef.current!.promise const response = await httpClient.request( 'POST', `${baseUrl}/call/`, { fn: functionName, args: input } ) const data: FunctionResponse = await response.json() if (data.error) { throw new DjangoError(data as FunctionErrorResponse) } return data.data }, [connection, baseUrl, httpClient] ) // Get cached context data const getContext = useCallback( (name: string): T | undefined => { return contextStore[name] as T | undefined }, [contextStore] ) // Refresh a specific context const refreshContext = useCallback( async (name: string): Promise => { try { const data = await call(name, {}) setContextStore(prev => { const next = { ...prev, [name]: data } // Notify listeners const listeners = contextListenersRef.current.get(name) if (listeners) { listeners.forEach(listener => { try { listener(data) } catch (e) { console.error(`[mizan] Context listener error for '${name}':`, e) } }) } return next }) } catch (e) { console.error(`[mizan] Failed to refresh context '${name}':`, e) throw e } }, [call] ) // Refresh all registered contexts const refreshAllContexts = useCallback( async (): Promise => { await Promise.all(contextNames.map(name => refreshContext(name))) }, [contextNames, refreshContext] ) // Subscribe to context changes const onContextChange = useCallback( (name: string, listener: (data: unknown) => void): (() => void) => { const listeners = contextListenersRef.current.get(name) ?? new Set() listeners.add(listener) contextListenersRef.current.set(name, listeners) return () => { const nameListeners = contextListenersRef.current.get(name) if (nameListeners) { nameListeners.delete(listener) if (nameListeners.size === 0) { contextListenersRef.current.delete(name) } } } }, [] ) // Subscribe to push messages const onPush = useCallback( (topic: string, listener: PushListener): (() => void) => { const listeners = pushListenersRef.current.get(topic) ?? new Set() listeners.add(listener as PushListener) pushListenersRef.current.set(topic, listeners) return () => { const topicListeners = pushListenersRef.current.get(topic) if (topicListeners) { topicListeners.delete(listener as PushListener) if (topicListeners.size === 0) { pushListenersRef.current.delete(topic) } } } }, [] ) // Connect on mount and listen for push messages useEffect(() => { const unsubscribeStatus = connection.onStatusChange((newStatus) => { setStatus(newStatus as ConnectionStatus) }) // Listen for all messages (including push) const unsubscribeMessages = connection.onMessage((payload) => { if (payload && typeof payload === 'object' && 'type' in payload && payload.type === 'push') { const topic = (payload as { topic?: string }).topic const data = (payload as { data?: unknown }).data if (topic) { const listeners = pushListenersRef.current.get(topic) if (listeners) { const message: PushMessage = { topic, data } listeners.forEach(listener => { try { listener(message) } catch (e) { console.error('[mizan] Push listener error:', e) } }) } } } }) if (autoConnect) { connection.connect() } return () => { unsubscribeStatus() unsubscribeMessages() } }, [connection, autoConnect]) // Session init for CSR (fallback if proxy didn't run) useEffect(() => { if (hasJWT || getCSRFToken()) { setSessionReady(true) sessionRef.current?.resolve() return } fetch(`${baseUrl}/session/`, { credentials: 'include' }) .catch(e => console.error('[MizanProvider] Session init failed:', e)) .finally(() => { setSessionReady(true) sessionRef.current?.resolve() }) }, [hasJWT, baseUrl]) // Auto-fetch contexts that weren't hydrated useEffect(() => { if (!sessionReady) return if (!hydration) { refreshAllContexts() } else { const missing = contextNames.filter(name => !(name in hydration)) if (missing.length > 0) { Promise.all(missing.map(name => refreshContext(name))) } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionReady]) const isRPCAvailable = status === 'connected' // Named context provider registry for invalidation const contextProvidersRef = useRef Promise }>>(new Map()) const registerContextProvider = useCallback( (name: string, refetch: () => Promise): (() => void) => { contextProvidersRef.current.set(name, { refetch }) return () => { contextProvidersRef.current.delete(name) } }, [] ) const invalidateContext = useCallback( async (name: string): Promise => { const provider = contextProvidersRef.current.get(name) if (provider) { await provider.refetch() } // If not mounted, no-op — no wasted request }, [] ) const invalidateFunctions = useCallback( async (names: string[]): Promise => { // Each function belongs to a context. Invalidating a function // means refetching its entire context (since the bundling endpoint // returns all functions). Dedupe by context name. const contexts = new Set() for (const name of names) { // The context name for each function is known at codegen time // and baked into the generated hook. Here we just invalidate // whatever contexts are registered that contain these functions. for (const [ctxName] of contextProvidersRef.current) { contexts.add(ctxName) } } await Promise.all( Array.from(contexts).map(ctx => invalidateContext(ctx)) ) }, [invalidateContext] ) // Set context data directly (used by generated providers that fetch bundles) const setContextData = useCallback( (name: string, data: unknown) => { setContextStore(prev => { const next = { ...prev, [name]: data } const listeners = contextListenersRef.current.get(name) if (listeners) { listeners.forEach(listener => { try { listener(data) } catch (e) { console.error(`[mizan] Context listener error for '${name}':`, e) } }) } return next }) }, [] ) // Auth-transparent HTTP request (used by generated context providers) const request = useCallback( async (method: string, path: string, data?: unknown): Promise => { await sessionRef.current!.promise return httpClient.request(method, path, data) }, [httpClient] ) const value = useMemo( () => ({ call, getContext, refreshContext, refreshAllContexts, status, isRPCAvailable, onPush, onContextChange, whenReady: sessionRef.current!.promise, invalidateContext, invalidateFunctions, registerContextProvider, baseUrl, setContextData, request, }), [call, getContext, refreshContext, refreshAllContexts, status, isRPCAvailable, onPush, onContextChange, invalidateContext, invalidateFunctions, registerContextProvider, baseUrl, setContextData, request] ) return ( {children} ) } // ============================================================================ // Hooks // ============================================================================ /** * Access the mizan context. * * Provides generic name-based API for server functions and contexts. * Libraries should use this hook, not the typed generated hooks. * * @example * ```tsx * // Library code (e.g., Allauth) * import { useMizan } from 'mizan' * * function useUser() { * const { getContext } = useMizan() * return getContext('current_user') * } * ``` */ export function useMizan(): MizanContextValue { const context = useReactContext(MizanContextInternal) if (!context) { throw new Error('useMizan must be used within a MizanProvider') } return context } /** * Get cached context data by name. * * For use by libraries that need to access context data without knowing types. * * @example * ```tsx * // In Allauth library * function useUser() { * return useMizanContext('current_user') * } * ``` */ export function useMizanContext(name: string): T | undefined { const { getContext } = useMizan() return getContext(name) } /** * Get a function caller by name with transport control. * * For use by libraries that need to call functions without knowing types. * The transport parameter is baked into the returned function. * * @param functionName - The server function name * @param transport - Transport mode ('http' or 'websocket', defaults to 'http') * * @example * ```tsx * // HTTP-only function (default) * function useUpdateProfile() { * return useMizanCall('update_profile') * } * * // WebSocket-enabled function * function useSendMessage() { * return useMizanCall('send_message', 'websocket') * } * ``` */ export function useMizanCall( functionName: string, transport: Transport = 'http' ): (input?: TInput) => Promise { const { call } = useMizan() return useCallback( (input?: TInput) => call(functionName, input, transport), [call, functionName, transport] ) } /** * Get the current WebSocket connection status. */ export function useMizanStatus(): ConnectionStatus { const { status } = useMizan() return status } /** * Subscribe to push messages for a topic. * Automatically unsubscribes when the component unmounts. */ export function usePush( topic: string, callback: PushListener ): void { const { onPush } = useMizan() const callbackRef = useRef(callback) useEffect(() => { callbackRef.current = callback }, [callback]) useEffect(() => { const listener: PushListener = (message) => { callbackRef.current(message) } return onPush(topic, listener) }, [topic, onPush]) } // ============================================================================ // Legacy Aliases (for backwards compatibility during migration) // ============================================================================ /** @deprecated Use MizanProvider instead */ export const DjangoContext = MizanProvider /** @deprecated Use useMizan instead */ export const useDjango = useMizan /** @deprecated Use useMizanStatus instead */ export const useDjangoStatus = useMizanStatus /** @deprecated Use useMizanCall instead */ export function useServerFunction( functionName: string ): (input: TInput) => Promise { const { call } = useMizan() return useCallback( (input: TInput) => call(functionName, input), [call, functionName] ) } // Re-export types for the legacy API export type DjangoContextValue = MizanContextValue export type DjangoContextProps = MizanProviderProps