Files
mizan/packages/mizan-react/src/context.tsx
Ryth Azhur 787f90fd12 Flatten to three packages + extract mizan-runtime
packages/
  mizan-runtime/   Framework-agnostic state engine (~150 lines)
                   Context registry, batched invalidation, fetch primitives
  mizan-django/    Django server adapter (was packages/mizan-rpc/adapters/django/)
                   Codegen moved to mizan-django/generate/
  mizan-react/     React adapter (was packages/mizan-csr/adapters/react/)

Removed premature abstractions: mizan-ast, mizan-schema, mizan-rpc,
mizan-csr, mizan-ssr stub packages. The actual architecture is three
concrete packages, not five abstract layers.

mizan-runtime implements the v1 spec: registerContext with params,
scoped invalidation via microtask batching, server-driven invalidation
from mutation responses, mizanFetch for context bundles, mizanCall for
mutations.

264 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:41:31 -04:00

752 lines
24 KiB
TypeScript

'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<T = unknown> {
topic: string
data: T
}
/** Listener for push messages */
export type PushListener<T = unknown> = (message: PushMessage<T>) => void
/** Context data store - maps context names to their data */
export type ContextStore = Record<string, unknown>
/** Hydration data for SSR - maps context names to their initial data */
export type MizanHydration = Record<string, unknown>
/** 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: <TInput = unknown, TOutput = unknown>(
functionName: string,
input?: TInput,
transport?: Transport
) => Promise<TOutput>
/**
* Get cached context data by name.
* Returns undefined if the context hasn't been loaded yet.
*/
getContext: <T = unknown>(name: string) => T | undefined
/**
* Refresh a specific context by name.
* Fetches fresh data from the server and updates the cache.
*/
refreshContext: (name: string) => Promise<void>
/**
* Refresh all registered contexts.
*/
refreshAllContexts: () => Promise<void>
/**
* 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: <T = unknown>(topic: string, listener: PushListener<T>) => () => 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<void>
/**
* 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<void>
/**
* Invalidate specific functions within their contexts.
* Groups by context and calls invalidateContext per group.
*/
invalidateFunctions: (names: string[]) => Promise<void>
/**
* 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>,
) => () => 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<Response>
}
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<MizanContextValue | null>(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<ChannelConnection | null>(null)
// Push listeners: Map<topic, Set<listener>>
const pushListenersRef = useRef<Map<string, Set<PushListener>>>(new Map())
// Context change listeners: Map<name, Set<listener>>
const contextListenersRef = useRef<Map<string, Set<(data: unknown) => void>>>(new Map())
// Context data store
const [contextStore, setContextStore] = useState<ContextStore>(() => {
// 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<void>; resolve: () => void } | null>(null)
if (!sessionRef.current) {
let resolve!: () => void
const promise = new Promise<void>(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<ConnectionStatus>(
connection.status as ConnectionStatus
)
// The core call function: HTTP-first, WebSocket opt-in
const call = useCallback(
async <TInput = unknown, TOutput = unknown>(
functionName: string,
input?: TInput,
transport: Transport = 'http'
): Promise<TOutput> => {
// Only attempt WebSocket if explicitly requested AND connected
if (transport === 'websocket' && connection.status === 'connected') {
try {
return await connection.rpc<TInput, TOutput>(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<TOutput> = 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(
<T = unknown>(name: string): T | undefined => {
return contextStore[name] as T | undefined
},
[contextStore]
)
// Refresh a specific context
const refreshContext = useCallback(
async (name: string): Promise<void> => {
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<void> => {
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(
<T = unknown>(topic: string, listener: PushListener<T>): (() => 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<Map<string, { refetch: () => Promise<void> }>>(new Map())
const registerContextProvider = useCallback(
(name: string, refetch: () => Promise<void>): (() => void) => {
contextProvidersRef.current.set(name, { refetch })
return () => {
contextProvidersRef.current.delete(name)
}
},
[]
)
const invalidateContext = useCallback(
async (name: string): Promise<void> => {
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<void> => {
// 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<string>()
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<Response> => {
await sessionRef.current!.promise
return httpClient.request(method, path, data)
},
[httpClient]
)
const value = useMemo<MizanContextValue>(
() => ({
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 (
<MizanContextInternal value={value}>
{children}
</MizanContextInternal>
)
}
// ============================================================================
// 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<T = unknown>(name: string): T | undefined {
const { getContext } = useMizan()
return getContext<T>(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<TInput = unknown, TOutput = unknown>(
functionName: string,
transport: Transport = 'http'
): (input?: TInput) => Promise<TOutput> {
const { call } = useMizan()
return useCallback(
(input?: TInput) => call<TInput, TOutput>(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<T = unknown>(
topic: string,
callback: PushListener<T>
): void {
const { onPush } = useMizan()
const callbackRef = useRef(callback)
useEffect(() => {
callbackRef.current = callback
}, [callback])
useEffect(() => {
const listener: PushListener<T> = (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<TInput = unknown, TOutput = unknown>(
functionName: string
): (input: TInput) => Promise<TOutput> {
const { call } = useMizan()
return useCallback(
(input: TInput) => call<TInput, TOutput>(functionName, input),
[call, functionName]
)
}
// Re-export types for the legacy API
export type DjangoContextValue = MizanContextValue
export type DjangoContextProps = MizanProviderProps