diff --git a/packages/mizan-react/src/client/index.ts b/packages/mizan-react/src/client/index.ts index 89fe2a4..d40f60f 100644 --- a/packages/mizan-react/src/client/index.ts +++ b/packages/mizan-react/src/client/index.ts @@ -148,19 +148,7 @@ export class HttpError extends Error { // Internal Utilities // ============================================================================= -function getCookie(name: string): string | null { - if (typeof document === 'undefined') return null - const value = `; ${document.cookie}` - const parts = value.split(`; ${name}=`) - if (parts.length === 2) { - return parts.pop()?.split(';').shift() ?? null - } - return null -} - -function getCSRFToken(): string | null { - return getCookie('csrftoken') -} +import { getCSRFToken } from '../utils' interface RequestBuild { request: RequestInit @@ -537,49 +525,3 @@ export interface FunctionSuccessResponse { */ export type FunctionResponse = FunctionSuccessResponse | FunctionErrorResponse -// Cached CSR client for server function calls -let _csrClient: DjangoHTTPClient | null = null - -function getCSRClient(): DjangoHTTPClient { - if (!_csrClient) { - _csrClient = createDjangoCSRClient(Auth.SESSION) - } - return _csrClient -} - -/** - * Call a Django server function via HTTP. - * Used as fallback when WebSocket is unavailable. - * - * Uses the standard CSR client with session-based auth. - * - * @param baseUrl - Base URL for the API (e.g., '/api/mizan') - * @param functionName - Name of the server function - * @param input - Input data for the function - * @returns Promise resolving to the function output - * @throws FunctionErrorResponse on failure - */ -export async function httpFunctionCall( - baseUrl: string, - functionName: string, - input?: TInput -): Promise { - const client = getCSRClient() - - // Use request() not json() because server functions return { error: true/false } - // in the body, not HTTP status codes for business errors - const response = await client.request( - 'POST', - `${baseUrl}/call/`, - { fn: functionName, args: input } - ) - - const data: FunctionResponse = await response.json() - - if (data.error) { - throw new DjangoError(data) - } - - return (data as FunctionSuccessResponse).result -} - diff --git a/packages/mizan-react/src/context.tsx b/packages/mizan-react/src/context.tsx index d4a6fa1..103b492 100644 --- a/packages/mizan-react/src/context.tsx +++ b/packages/mizan-react/src/context.tsx @@ -40,15 +40,7 @@ import { 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 -} +import { getCSRFToken } from './utils' // ============================================================================ // Types @@ -546,18 +538,10 @@ export function MizanProvider({ 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) - } - } + // Function names are passed directly as context invalidation targets. + // The server already resolved function → context mapping. + // Dedupe and invalidate each. + const contexts = new Set(names) await Promise.all( Array.from(contexts).map(ctx => invalidateContext(ctx)) ) diff --git a/packages/mizan-react/src/index.ts b/packages/mizan-react/src/index.ts index 3a0ad62..7620227 100644 --- a/packages/mizan-react/src/index.ts +++ b/packages/mizan-react/src/index.ts @@ -62,7 +62,6 @@ export { // ============================================================================ export { - httpFunctionCall, createDjangoCSRClient, createDjangoSSRClient, ensureDjangoSession, diff --git a/packages/mizan-react/src/jwt/JWTContext.tsx b/packages/mizan-react/src/jwt/JWTContext.tsx index d2c6808..6c12b70 100644 --- a/packages/mizan-react/src/jwt/JWTContext.tsx +++ b/packages/mizan-react/src/jwt/JWTContext.tsx @@ -10,12 +10,7 @@ import { type ReactNode, } from 'react' import type { JWTTokens, JWTConfig, JWTState } from '../client/types' - -function getCSRFToken(): string | null { - if (typeof document === 'undefined') return null - const match = document.cookie.match(/csrftoken=([^;]+)/) - return match?.[1] ?? null -} +import { getCSRFToken } from '../utils' const Context = createContext(null) diff --git a/packages/mizan-react/src/runtime/index.ts b/packages/mizan-react/src/runtime/index.ts deleted file mode 100644 index c80d80e..0000000 --- a/packages/mizan-react/src/runtime/index.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Mizan Runtime — The client state engine. - * - * Framework-agnostic. React, Vue, Svelte, Solid — all wrap this. - * - * Three concerns: - * 1. Context registry — mounted providers register here for invalidation - * 2. Invalidation — batched via microtask, supports scoped params - * 3. Fetch — mizanFetch (GET context bundles) + mizanCall (POST mutations) - */ - -// === Types === - -export class MizanError extends Error { - constructor(public status: number, public body: string) { - super(`Mizan call failed (${status})`) - } -} - -export type RefetchFn = () => void -type ParamKey = string // JSON.stringify of params - -interface ContextEntry { - params: Record - refetch: RefetchFn -} - -// === Configuration === - -let config = { - baseUrl: '/api/mizan', - getHeaders: (): Record => ({}), -} - -export function configure(opts: Partial) { - Object.assign(config, opts) -} - -// === Context Registry === -// Mounted context providers register here. Unmounted ones deregister. - -const contexts: Map> = new Map() - -export function registerContext( - name: string, - params: Record, - refetch: RefetchFn, -): () => void { - if (!contexts.has(name)) contexts.set(name, new Map()) - const key = JSON.stringify(params) - contexts.get(name)!.set(key, { params, refetch }) - return () => contexts.get(name)!.delete(key) -} - -// === Invalidation === -// Batched via microtask. Multiple invalidations in the same tick coalesce. - -const pending: Set = new Set() -const pendingScoped: Map> = new Map() -let scheduled = false - -export function invalidate(context: string, params?: Record) { - if (params) { - pendingScoped.set(context, params) - } else { - pending.add(context) - } - if (!scheduled) { - scheduled = true - queueMicrotask(flush) - } -} - -function flush() { - // Broad invalidations — refetch all instances of context - for (const name of pending) { - const entries = contexts.get(name) - if (entries) entries.forEach(entry => entry.refetch()) - } - - // Scoped invalidations — refetch only matching params - for (const [name, params] of pendingScoped) { - if (pending.has(name)) continue // already refetched all - const entries = contexts.get(name) - if (!entries) continue - const key = JSON.stringify(params) - const entry = entries.get(key) - if (entry) entry.refetch() - } - - pending.clear() - pendingScoped.clear() - scheduled = false -} - -// === Fetch === - -export async function mizanFetch( - contextName: string, - params?: Record, -): Promise { - const url = new URL(`${config.baseUrl}/ctx/${contextName}/`, globalThis.location?.origin ?? 'http://localhost') - if (params) { - for (const [k, v] of Object.entries(params)) { - url.searchParams.set(k, String(v)) - } - } - const res = await fetch(url.toString(), { - headers: { ...config.getHeaders(), 'Accept': 'application/json' }, - credentials: 'same-origin', - }) - if (!res.ok) throw new MizanError(res.status, await res.text()) - return res.json() -} - -export async function mizanCall( - functionName: string, - args: Record, -): Promise { - const res = await fetch(`${config.baseUrl}/call/`, { - method: 'POST', - headers: { - ...config.getHeaders(), - 'Content-Type': 'application/json', - }, - credentials: 'same-origin', - body: JSON.stringify({ fn: functionName, args }), - }) - if (!res.ok) throw new MizanError(res.status, await res.text()) - - const data = await res.json() - - // Server-driven invalidation - if (data.invalidate) { - for (const entry of data.invalidate) { - if (typeof entry === 'string') { - invalidate(entry) - } else { - invalidate(entry.context, entry.params) - } - } - } - - return data.result -} diff --git a/packages/mizan-react/src/utils.ts b/packages/mizan-react/src/utils.ts new file mode 100644 index 0000000..bb6c566 --- /dev/null +++ b/packages/mizan-react/src/utils.ts @@ -0,0 +1,10 @@ +/** + * Shared utilities used across mizan-react. + */ + +/** Extract CSRF token from cookies. Returns null during SSR. */ +export function getCSRFToken(): string | null { + if (typeof document === 'undefined') return null + const match = document.cookie.match(/csrftoken=([^;]+)/) + return match?.[1] ?? null +}