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>
This commit is contained in:
751
packages/mizan-react/src/context.tsx
Normal file
751
packages/mizan-react/src/context.tsx
Normal file
@@ -0,0 +1,751 @@
|
||||
'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
|
||||
Reference in New Issue
Block a user