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>
752 lines
24 KiB
TypeScript
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
|