diff --git a/packages/mizan-react/src/__tests__/context.test.tsx b/packages/mizan-react/src/__tests__/context.test.tsx index 964af48..759b5a9 100644 --- a/packages/mizan-react/src/__tests__/context.test.tsx +++ b/packages/mizan-react/src/__tests__/context.test.tsx @@ -15,12 +15,12 @@ import { useMizanStatus, useMizanCall, // Legacy aliases for backwards compatibility tests - DjangoContext, + MizanProvider, useDjango, - useDjangoStatus, - useServerFunction, + useMizanStatus, + useMizanCall, } from '../context' -import { DjangoError } from '../errors' +import { MizanError } from '../errors' import { describeIntegration, BACKEND_URL } from '../testing' // ============================================================================ @@ -117,7 +117,7 @@ describeIntegration('mizan Context (integration)', () => { let error: any = null function TestComponent() { - const { call, status } = useDjango() + const { call, status } = useMizan() React.useEffect(() => { // Use HTTP fallback (status will be disconnected without WebSocket) @@ -130,9 +130,9 @@ describeIntegration('mizan Context (integration)', () => { } render( - + - + ) await waitFor(() => { @@ -149,7 +149,7 @@ describeIntegration('mizan Context (integration)', () => { let error: any = null function TestComponent() { - const { call } = useDjango() + const { call } = useMizan() React.useEffect(() => { call<{ a: number; b: number }, { result: number }>('add', { a: 10, b: 20 }) @@ -161,9 +161,9 @@ describeIntegration('mizan Context (integration)', () => { } render( - + - + ) await waitFor(() => { @@ -174,12 +174,12 @@ describeIntegration('mizan Context (integration)', () => { expect(result).toEqual({ result: 30 }) }) - it('should throw DjangoError for validation errors', async () => { + it('should throw MizanError for validation errors', async () => { let result: any = null let error: any = null function TestComponent() { - const { call } = useDjango() + const { call } = useMizan() React.useEffect(() => { // Call without required field @@ -192,9 +192,9 @@ describeIntegration('mizan Context (integration)', () => { } render( - + - + ) await waitFor(() => { @@ -202,11 +202,11 @@ describeIntegration('mizan Context (integration)', () => { }, { timeout: 5000 }) expect(result).toBeNull() - expect(error).toBeInstanceOf(DjangoError) + expect(error).toBeInstanceOf(MizanError) }) }) - describe('useServerFunction hook', () => { + describe('useMizanCall hook', () => { it('should create typed function that calls backend', async () => { let result: any = null let error: any = null @@ -215,7 +215,7 @@ describeIntegration('mizan Context (integration)', () => { interface EchoOutput { message: string } function TestComponent() { - const echo = useServerFunction('echo') + const echo = useMizanCall('echo') React.useEffect(() => { echo({ text: 'typed function test' }) @@ -227,9 +227,9 @@ describeIntegration('mizan Context (integration)', () => { } render( - + - + ) await waitFor(() => { @@ -248,7 +248,7 @@ describeIntegration('mizan Context (integration)', () => { let error: any = null function TestComponent() { - const { call } = useDjango() + const { call } = useMizan() React.useEffect(() => { call('login.schema', { data: {} }) @@ -260,9 +260,9 @@ describeIntegration('mizan Context (integration)', () => { } render( - + - + ) await waitFor(() => { @@ -282,7 +282,7 @@ describeIntegration('mizan Context (integration)', () => { let error: any = null function TestComponent() { - const { call } = useDjango() + const { call } = useMizan() React.useEffect(() => { call('login.validate', { @@ -296,9 +296,9 @@ describeIntegration('mizan Context (integration)', () => { } render( - + - + ) await waitFor(() => { diff --git a/packages/mizan-react/src/__tests__/errors.test.ts b/packages/mizan-react/src/__tests__/errors.test.ts index fdad8ad..dedfe9a 100644 --- a/packages/mizan-react/src/__tests__/errors.test.ts +++ b/packages/mizan-react/src/__tests__/errors.test.ts @@ -2,9 +2,9 @@ * Tests for Django Server Error */ -import { DjangoError, type FunctionErrorResponse } from '../errors' +import { MizanError, type FunctionErrorResponse } from '../errors' -describe('DjangoError', () => { +describe('MizanError', () => { it('should create error with message and code', () => { const response: FunctionErrorResponse = { error: true, @@ -12,11 +12,11 @@ describe('DjangoError', () => { message: 'Function not found', } - const error = new DjangoError(response) + const error = new MizanError(response) expect(error.message).toBe('Function not found') expect(error.code).toBe('NOT_FOUND') - expect(error.name).toBe('DjangoError') + expect(error.name).toBe('MizanError') }) it('should preserve details', () => { @@ -32,7 +32,7 @@ describe('DjangoError', () => { }, } - const error = new DjangoError(response) + const error = new MizanError(response) expect(error.details).toBeDefined() expect(error.details?.fields?.name).toEqual(['Required', 'Too short']) @@ -45,14 +45,14 @@ describe('DjangoError', () => { message: 'Server error', } - const error = new DjangoError(response) + const error = new MizanError(response) expect(error.response).toBe(response) }) describe('isValidationError', () => { it('should return true for validation errors', () => { - const error = new DjangoError({ + const error = new MizanError({ error: true, code: 'VALIDATION_ERROR', message: 'Invalid', @@ -62,7 +62,7 @@ describe('DjangoError', () => { }) it('should return false for other errors', () => { - const error = new DjangoError({ + const error = new MizanError({ error: true, code: 'NOT_FOUND', message: 'Not found', @@ -74,7 +74,7 @@ describe('DjangoError', () => { describe('isAuthError', () => { it('should return true for unauthorized', () => { - const error = new DjangoError({ + const error = new MizanError({ error: true, code: 'UNAUTHORIZED', message: 'Not authenticated', @@ -84,7 +84,7 @@ describe('DjangoError', () => { }) it('should return true for forbidden', () => { - const error = new DjangoError({ + const error = new MizanError({ error: true, code: 'FORBIDDEN', message: 'Access denied', @@ -94,7 +94,7 @@ describe('DjangoError', () => { }) it('should return false for other errors', () => { - const error = new DjangoError({ + const error = new MizanError({ error: true, code: 'NOT_FOUND', message: 'Not found', @@ -106,7 +106,7 @@ describe('DjangoError', () => { describe('isNotFound', () => { it('should return true for not found errors', () => { - const error = new DjangoError({ + const error = new MizanError({ error: true, code: 'NOT_FOUND', message: 'Not found', @@ -116,7 +116,7 @@ describe('DjangoError', () => { }) it('should return false for other errors', () => { - const error = new DjangoError({ + const error = new MizanError({ error: true, code: 'VALIDATION_ERROR', message: 'Invalid', @@ -128,7 +128,7 @@ describe('DjangoError', () => { describe('getFieldErrors', () => { it('should return field errors for validation error', () => { - const error = new DjangoError({ + const error = new MizanError({ error: true, code: 'VALIDATION_ERROR', message: 'Invalid input', @@ -149,7 +149,7 @@ describe('DjangoError', () => { }) it('should return null for non-validation errors', () => { - const error = new DjangoError({ + const error = new MizanError({ error: true, code: 'NOT_FOUND', message: 'Not found', @@ -159,7 +159,7 @@ describe('DjangoError', () => { }) it('should return null if no fields in details', () => { - const error = new DjangoError({ + const error = new MizanError({ error: true, code: 'VALIDATION_ERROR', message: 'Invalid', @@ -172,7 +172,7 @@ describe('DjangoError', () => { describe('getFieldError', () => { it('should return first error for a field', () => { - const error = new DjangoError({ + const error = new MizanError({ error: true, code: 'VALIDATION_ERROR', message: 'Invalid input', @@ -187,7 +187,7 @@ describe('DjangoError', () => { }) it('should return null for non-existent field', () => { - const error = new DjangoError({ + const error = new MizanError({ error: true, code: 'VALIDATION_ERROR', message: 'Invalid input', @@ -202,7 +202,7 @@ describe('DjangoError', () => { }) it('should return null for non-validation errors', () => { - const error = new DjangoError({ + const error = new MizanError({ error: true, code: 'NOT_FOUND', message: 'Not found', diff --git a/packages/mizan-react/src/__tests__/integration.test.tsx b/packages/mizan-react/src/__tests__/integration.test.tsx index 98ba739..2d0ab5a 100644 --- a/packages/mizan-react/src/__tests__/integration.test.tsx +++ b/packages/mizan-react/src/__tests__/integration.test.tsx @@ -11,7 +11,7 @@ import { renderHook, act } from '@testing-library/react' import { ReactNode } from 'react' import { describeIntegration, BACKEND_URL, WS_URL } from '../testing' import { MizanProvider, useMizan } from '../context' -import { DjangoError } from '../errors' +import { MizanError } from '../errors' import { ChannelConnection } from '../channels/connection' import { RPCError } from '../channels/connection' @@ -47,16 +47,16 @@ describeIntegration('Executor framework validation', () => { it('should return VALIDATION_ERROR with field details for wrong input types', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) - let error: DjangoError | null = null + let error: MizanError | null = null await act(async () => { try { await result.current('add', { a: 'hello', b: 'world' }) } catch (e) { - error = e as DjangoError + error = e as MizanError } }) - expect(error).toBeInstanceOf(DjangoError) + expect(error).toBeInstanceOf(MizanError) expect(error!.code).toBe('VALIDATION_ERROR') expect(error!.isValidationError()).toBe(true) const fieldErrors = error!.getFieldErrors() @@ -67,48 +67,48 @@ describeIntegration('Executor framework validation', () => { it('should return NOT_FOUND for non-existent function', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) - let error: DjangoError | null = null + let error: MizanError | null = null await act(async () => { try { await result.current('this_function_does_not_exist', {}) } catch (e) { - error = e as DjangoError + error = e as MizanError } }) - expect(error).toBeInstanceOf(DjangoError) + expect(error).toBeInstanceOf(MizanError) expect(error!.code).toBe('NOT_FOUND') }) it('should return FORBIDDEN for auth-required function when anonymous', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) - let error: DjangoError | null = null + let error: MizanError | null = null await act(async () => { try { await result.current('whoami', {}) } catch (e) { - error = e as DjangoError + error = e as MizanError } }) - expect(error).toBeInstanceOf(DjangoError) + expect(error).toBeInstanceOf(MizanError) expect(error!.isAuthError()).toBe(true) }) it('should return VALIDATION_ERROR with specific field for missing required input', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) - let error: DjangoError | null = null + let error: MizanError | null = null await act(async () => { try { await result.current('echo', {}) } catch (e) { - error = e as DjangoError + error = e as MizanError } }) - expect(error).toBeInstanceOf(DjangoError) + expect(error).toBeInstanceOf(MizanError) expect(error!.code).toBe('VALIDATION_ERROR') const fieldErrors = error!.getFieldErrors() expect(fieldErrors).not.toBeNull() @@ -357,16 +357,16 @@ describeIntegration('Auth variations', () => { it('should reject staff_only for anonymous with UNAUTHORIZED', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) - let error: DjangoError | null = null + let error: MizanError | null = null await act(async () => { try { await result.current('staff_only', {}) } catch (e) { - error = e as DjangoError + error = e as MizanError } }) - expect(error).toBeInstanceOf(DjangoError) + expect(error).toBeInstanceOf(MizanError) expect(error!.code).toBe('UNAUTHORIZED') expect(error!.isAuthError()).toBe(true) }) @@ -374,16 +374,16 @@ describeIntegration('Auth variations', () => { it('should reject superuser_only for anonymous with UNAUTHORIZED', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) - let error: DjangoError | null = null + let error: MizanError | null = null await act(async () => { try { await result.current('superuser_only', {}) } catch (e) { - error = e as DjangoError + error = e as MizanError } }) - expect(error).toBeInstanceOf(DjangoError) + expect(error).toBeInstanceOf(MizanError) expect(error!.code).toBe('UNAUTHORIZED') expect(error!.isAuthError()).toBe(true) }) @@ -391,17 +391,17 @@ describeIntegration('Auth variations', () => { it('should reject verified_only for anonymous (callable auth)', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) - let error: DjangoError | null = null + let error: MizanError | null = null await act(async () => { try { await result.current('verified_only', {}) } catch (e) { - error = e as DjangoError + error = e as MizanError } }) // Callable auth returns False for anonymous, which maps to FORBIDDEN - expect(error).toBeInstanceOf(DjangoError) + expect(error).toBeInstanceOf(MizanError) expect(error!.code).toBe('FORBIDDEN') expect(error!.isAuthError()).toBe(true) }) @@ -445,48 +445,48 @@ describeIntegration('Error code coverage', () => { it('should return NOT_IMPLEMENTED for NotImplementedError', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) - let error: DjangoError | null = null + let error: MizanError | null = null await act(async () => { try { await result.current('not_implemented_fn', {}) } catch (e) { - error = e as DjangoError + error = e as MizanError } }) - expect(error).toBeInstanceOf(DjangoError) + expect(error).toBeInstanceOf(MizanError) expect(error!.code).toBe('NOT_IMPLEMENTED') }) it('should return INTERNAL_ERROR for unhandled RuntimeError', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) - let error: DjangoError | null = null + let error: MizanError | null = null await act(async () => { try { await result.current('buggy_fn', {}) } catch (e) { - error = e as DjangoError + error = e as MizanError } }) - expect(error).toBeInstanceOf(DjangoError) + expect(error).toBeInstanceOf(MizanError) expect(error!.code).toBe('INTERNAL_ERROR') }) it('should return FORBIDDEN for PermissionError with wrong secret', async () => { const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) - let error: DjangoError | null = null + let error: MizanError | null = null await act(async () => { try { await result.current('permission_check_fn', { secret: 'wrong' }) } catch (e) { - error = e as DjangoError + error = e as MizanError } }) - expect(error).toBeInstanceOf(DjangoError) + expect(error).toBeInstanceOf(MizanError) expect(error!.code).toBe('FORBIDDEN') expect(error!.isAuthError()).toBe(true) }) diff --git a/packages/mizan-react/src/channels/hooks.ts b/packages/mizan-react/src/channels/hooks.ts index 1cc4c68..4941359 100644 --- a/packages/mizan-react/src/channels/hooks.ts +++ b/packages/mizan-react/src/channels/hooks.ts @@ -15,12 +15,12 @@ import type { IncomingPayload, } from './types' -export interface UseChannelOptions { +export interface UseChannelOptions { /** Called when subscribed successfully */ onSubscribed?: () => void /** Called when a message is received */ - onMessage?: (message: TDjangoMessage) => void + onMessage?: (message: TServerMessage) => void /** Called on error */ onError?: (error: string) => void @@ -41,16 +41,16 @@ export interface UseChannelOptions { */ export function useChannel< TParams = undefined, - TDjangoMessage = unknown, + TServerMessage = unknown, TReactMessage = unknown, >( channelName: string, params?: TParams, - options: UseChannelOptions = {}, -): ChannelSubscription { + options: UseChannelOptions = {}, +): ChannelSubscription { const { connection, status: connectionStatus } = useChannelContext() - const [messages, setMessages] = useState([]) + const [messages, setMessages] = useState([]) const [subscribed, setSubscribed] = useState(false) const optionsRef = useRef(options) @@ -104,7 +104,7 @@ export function useChannel< // Handle data messages if ('type' in payload && 'data' in payload) { - const message = payload.data as TDjangoMessage + const message = payload.data as TServerMessage setMessages(prev => { const next = [...prev, message] // Trim to max messages @@ -166,7 +166,7 @@ export function useChannel< return { status, messages, - send: send as ChannelSubscription['send'], + send: send as ChannelSubscription['send'], unsubscribe, clearMessages, } @@ -177,16 +177,16 @@ export function useChannel< */ export function useChannelLatest< TParams = undefined, - TDjangoMessage = unknown, + TServerMessage = unknown, TReactMessage = unknown, >( channelName: string, params?: TParams, - options: UseChannelOptions = {}, -): Omit, 'messages'> & { latest: TDjangoMessage | null } { - const [latest, setLatest] = useState(null) + options: UseChannelOptions = {}, +): Omit, 'messages'> & { latest: TServerMessage | null } { + const [latest, setLatest] = useState(null) - const channel = useChannel( + const channel = useChannel( channelName, params, { diff --git a/packages/mizan-react/src/channels/types.ts b/packages/mizan-react/src/channels/types.ts index ade0769..0522297 100644 --- a/packages/mizan-react/src/channels/types.ts +++ b/packages/mizan-react/src/channels/types.ts @@ -4,12 +4,12 @@ export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' -export interface ChannelSubscription { +export interface ChannelSubscription { /** Current connection status */ status: ConnectionStatus /** Received messages */ - messages: TDjangoMessage[] + messages: TServerMessage[] /** Send a message (if channel accepts ReactMessage) */ send: TReactMessage extends never ? never : (message: TReactMessage) => void diff --git a/packages/mizan-react/src/client/index.ts b/packages/mizan-react/src/client/index.ts index d40f60f..972ea1d 100644 --- a/packages/mizan-react/src/client/index.ts +++ b/packages/mizan-react/src/client/index.ts @@ -8,22 +8,22 @@ * * ### Client-Side (CSR) * ```ts - * import { createDjangoCSRClient, Auth } from 'mizan/client' + * import { createMizanCSRClient, Auth } from 'mizan/client' * * // Session-based (cookies + CSRF) - * const client = createDjangoCSRClient(Auth.SESSION) + * const client = createMizanCSRClient(Auth.SESSION) * * // JWT-based (Bearer token) - * const client = createDjangoCSRClient(Auth.JWT, { getAccessToken }) + * const client = createMizanCSRClient(Auth.JWT, { getAccessToken }) * * const user = await client.json('GET', '/api/accounts/me/') * ``` * * ### Server-Side (SSR) * ```ts - * import { createDjangoSSRClient } from 'mizan/client' + * import { createMizanSSRClient } from 'mizan/client' * - * const client = createDjangoSSRClient({ + * const client = createMizanSSRClient({ * cookies: await cookies() // Next.js cookies() * }) * @@ -76,7 +76,7 @@ export type SSRCookies = CookieGetter | { /** * The core HTTP client interface for Django requests. */ -export interface DjangoHTTPClient { +export interface MizanHTTPClient { /** * Make an HTTP request, returning the raw Response. */ @@ -148,7 +148,7 @@ export class HttpError extends Error { // Internal Utilities // ============================================================================= -import { getCSRFToken } from '../utils' +import { getCSRFToken, getCsrfHeaderName, getCsrfCookieName } from '../utils' interface RequestBuild { request: RequestInit @@ -218,24 +218,24 @@ async function buildHttpError(resp: Response, url: URL | string): Promise localStorage.getItem('token') * }) */ -export function createDjangoCSRClient(auth: Auth.SESSION, config?: CSRClientConfig): DjangoHTTPClient -export function createDjangoCSRClient(auth: Auth.JWT, config: JWTClientConfig): DjangoHTTPClient -export function createDjangoCSRClient( +export function createMizanCSRClient(auth: Auth.SESSION, config?: CSRClientConfig): MizanHTTPClient +export function createMizanCSRClient(auth: Auth.JWT, config: JWTClientConfig): MizanHTTPClient +export function createMizanCSRClient( auth: Auth, config?: CSRClientConfig | JWTClientConfig -): DjangoHTTPClient { +): MizanHTTPClient { if (!config?.baseUrl) { throw new Error( 'baseUrl is required. Pass it via config or use MizanProvider which provides it automatically.' @@ -254,7 +254,7 @@ export function createDjangoCSRClient( return {} } // Session auth uses CSRF - return { 'X-CSRFToken': getCSRFToken() ?? '' } + return { [getCsrfHeaderName()]: getCSRFToken() ?? '' } } function resolveUrl(path: string): string { @@ -318,7 +318,7 @@ function isCookieGetter(cookies: SSRCookies): cookies is CookieGetter { function extractCookies(cookies: SSRCookies): { csrf: string; cookieHeader: string } { if (isCookieGetter(cookies)) { return { - csrf: cookies.get('csrftoken')?.value ?? '', + csrf: cookies.get(getCsrfCookieName())?.value ?? '', cookieHeader: cookies.getAll().map(c => `${c.name}=${c.value}`).join('; ') } } @@ -330,13 +330,13 @@ function extractCookies(cookies: SSRCookies): { csrf: string; cookieHeader: stri * Used in SSR contexts (Next.js server components, server actions, etc.) * * @param config - SSR client configuration with cookies - * @returns DjangoHTTPClient + * @returns MizanHTTPClient * * @example * // Next.js server component * import { cookies } from 'next/headers' * - * const client = createDjangoSSRClient({ cookies: await cookies() }) + * const client = createMizanSSRClient({ cookies: await cookies() }) */ // Re-export auth types for non-React usage export type { @@ -348,10 +348,7 @@ export type { JWTState, } from './types' -// Re-export RouterAdapter for libraries that extend it -export type { RouterAdapter } from './RouterContext' - -export function createDjangoSSRClient(config: SSRClientConfig): DjangoHTTPClient { +export function createMizanSSRClient(config: SSRClientConfig): MizanHTTPClient { const baseUrl = getInternalBackendUrl(config.baseUrl) const { csrf, cookieHeader } = extractCookies(config.cookies) @@ -361,7 +358,7 @@ export function createDjangoSSRClient(config: SSRClientConfig): DjangoHTTPClient const requestHeaders: Record = { 'Accept': 'application/json', - 'X-CSRFToken': csrf, + [getCsrfHeaderName()]: csrf, 'Cookie': cookieHeader, ...headers, } @@ -390,7 +387,7 @@ export function createDjangoSSRClient(config: SSRClientConfig): DjangoHTTPClient const requestHeaders: Record = { 'Accept': 'application/json', - 'X-CSRFToken': csrf, + [getCsrfHeaderName()]: csrf, 'Cookie': cookieHeader, ...headers, } @@ -454,12 +451,12 @@ interface SessionInitResponse { * @example * // In layout.tsx * const cookieStore = await cookies() - * const session = await ensureDjangoSession({ cookies: cookieStore }) - * const client = createDjangoSSRClient({ + * const session = await ensureMizanSession({ cookies: cookieStore }) + * const client = createMizanSSRClient({ * cookies: { csrf: session.csrf, cookieHeader: session.cookieHeader } * }) */ -export async function ensureDjangoSession(config: SSRClientConfig): Promise<{ +export async function ensureMizanSession(config: SSRClientConfig): Promise<{ csrf: string cookieHeader: string }> { @@ -510,7 +507,7 @@ export async function ensureDjangoSession(config: SSRClientConfig): Promise<{ // Re-export error types from the canonical location export type { FunctionErrorResponse } from '../errors' -import { DjangoError, type FunctionErrorResponse } from '../errors' +import { MizanError, type FunctionErrorResponse } from '../errors' /** * Success response from a server function diff --git a/packages/mizan-react/src/client/react.ts b/packages/mizan-react/src/client/react.ts index 77b2bc0..6397da4 100644 --- a/packages/mizan-react/src/client/react.ts +++ b/packages/mizan-react/src/client/react.ts @@ -3,9 +3,9 @@ import { useMemo } from 'react' import { useJWT } from '../jwt/JWTContext' import { - createDjangoCSRClient, + createMizanCSRClient, Auth, - type DjangoHTTPClient, + type MizanHTTPClient, type CSRClientConfig, } from './index' @@ -22,19 +22,19 @@ export type * from './types' * * @param auth - Authentication strategy (Auth.SESSION or Auth.JWT) * @param config - Optional client configuration - * @returns DjangoHTTPClient + * @returns MizanHTTPClient * * @example * // Session-based - * const client = useDjangoCSRClient(Auth.SESSION) + * const client = useMizanCSRClient(Auth.SESSION) * const user = await client.json('GET', '/api/accounts/me/') * * @example * // JWT-based (requires JWTContext from mizan/jwt) - * const client = useDjangoCSRClient(Auth.JWT) + * const client = useMizanCSRClient(Auth.JWT) * const user = await client.json('GET', '/api/accounts/me/') */ -export function useDjangoCSRClient(auth: Auth, config?: CSRClientConfig): DjangoHTTPClient { +export function useMizanCSRClient(auth: Auth, config?: CSRClientConfig): MizanHTTPClient { // Always call useJWT (React hooks must be unconditional) // Returns null when outside JWTContext const jwtContext = useJWT() @@ -43,16 +43,16 @@ export function useDjangoCSRClient(auth: Auth, config?: CSRClientConfig): Django if (auth === Auth.JWT) { if (!jwtContext?.getAccessToken) { throw new Error( - 'useDjangoCSRClient(Auth.JWT) requires JWTContext from mizan/jwt. ' + + 'useMizanCSRClient(Auth.JWT) requires JWTContext from mizan/jwt. ' + 'Wrap your component in JWTContext to use JWT authentication.' ) } - return createDjangoCSRClient(Auth.JWT, { + return createMizanCSRClient(Auth.JWT, { ...config, getAccessToken: jwtContext.getAccessToken, }) } - return createDjangoCSRClient(Auth.SESSION, config) + return createMizanCSRClient(Auth.SESSION, config) }, [auth, config, jwtContext?.getAccessToken]) } diff --git a/packages/mizan-react/src/context.tsx b/packages/mizan-react/src/context.tsx index 103b492..af19cb6 100644 --- a/packages/mizan-react/src/context.tsx +++ b/packages/mizan-react/src/context.tsx @@ -33,12 +33,12 @@ import { } from 'react' import { ChannelConnection, RPCError } from 'mizan/channels' import { - createDjangoCSRClient, + createMizanCSRClient, Auth, type FunctionResponse, } from 'mizan/client' import { useJWT } from './jwt' -import { DjangoError, type ErrorCode, type FunctionErrorResponse } from './errors' +import { MizanError, type ErrorCode, type FunctionErrorResponse } from './errors' import { getCSRFToken } from './utils' @@ -271,12 +271,12 @@ export function MizanProvider({ // Create HTTP client with appropriate auth method const httpClient = useMemo(() => { if (jwt?.getAccessToken) { - return createDjangoCSRClient(Auth.JWT, { + return createMizanCSRClient(Auth.JWT, { baseUrl, getAccessToken: jwt.getAccessToken, }) } - return createDjangoCSRClient(Auth.SESSION, { baseUrl }) + return createMizanCSRClient(Auth.SESSION, { baseUrl }) }, [hasJWT, jwt?.getAccessToken, baseUrl]) // Create or use provided connection @@ -308,9 +308,9 @@ export function MizanProvider({ try { return await connection.rpc(functionName, input as TInput) } catch (e) { - // If it's an RPC error (function error), re-throw as DjangoError + // If it's an RPC error (function error), re-throw as MizanError if (e instanceof RPCError) { - throw new DjangoError({ + throw new MizanError({ error: true, code: e.code as ErrorCode, message: e.message, @@ -338,7 +338,7 @@ export function MizanProvider({ const data = await response.json() if (data.error) { - throw new DjangoError(data as FunctionErrorResponse) + throw new MizanError(data as FunctionErrorResponse) } // Server-driven invalidation: process the invalidate array diff --git a/packages/mizan-react/src/errors.ts b/packages/mizan-react/src/errors.ts index 261e918..72933d4 100644 --- a/packages/mizan-react/src/errors.ts +++ b/packages/mizan-react/src/errors.ts @@ -1,5 +1,5 @@ /** - * Django Server Error Types + * Mizan Server Error Types * * Typed errors for server function failures. */ @@ -34,7 +34,7 @@ export interface FunctionErrorResponse { /** * Error thrown when a server function call fails */ -export class DjangoError extends Error { +export class MizanError extends Error { /** * Error code from the server */ @@ -52,14 +52,14 @@ export class DjangoError extends Error { constructor(response: FunctionErrorResponse) { super(response.message) - this.name = 'DjangoError' + this.name = 'MizanError' this.code = response.code this.details = response.details this.response = response // Maintains proper stack trace for where error was thrown if (Error.captureStackTrace) { - Error.captureStackTrace(this, DjangoError) + Error.captureStackTrace(this, MizanError) } } diff --git a/packages/mizan-react/src/forms.ts b/packages/mizan-react/src/forms.ts index a532763..6af940a 100644 --- a/packages/mizan-react/src/forms.ts +++ b/packages/mizan-react/src/forms.ts @@ -24,7 +24,7 @@ import { } from 'react' import type { ZodObject, ZodRawShape, ZodError } from 'zod' import { useMizan } from './context' -import { DjangoError } from './errors' +import { MizanError } from './errors' // Forms always use HTTP transport because Django Allauth and other auth // systems require full HTTP request semantics (session, cookies, CSRF). @@ -156,7 +156,7 @@ export type FormsetSubmitResult = Record> { +export interface MizanFormState> { /** Current form data - typed by TData */ data: TData @@ -354,7 +354,7 @@ export interface FormsetErrors> { /** * Typed formset state returned by formset hooks. */ -export interface DjangoFormsetState> { +export interface MizanFormsetState> { /** Array of form data objects - each typed by TData */ forms: TData[] @@ -449,7 +449,7 @@ export interface FormCoreConfig> { */ export function useMizanFormCore>( config: FormCoreConfig -): DjangoFormState { +): MizanFormState { const { name, zodSchema, options = {} } = config const { liveValidation: liveValidationOption = 'field-only', @@ -768,8 +768,8 @@ export function useMizanFormCore>( return { success: false, errors: typedErrors } } } catch (err) { - // Handle DjangoError with validation details - if (err instanceof DjangoError && err.isValidationError()) { + // Handle MizanError with validation details + if (err instanceof MizanError && err.isValidationError()) { const rawFieldErrors = err.getFieldErrors() const fields = {} as { [K in keyof TData]?: FieldError[] } @@ -894,7 +894,7 @@ export interface FormsetCoreConfig> { */ export function useMizanFormsetCore>( config: FormsetCoreConfig -): DjangoFormsetState { +): MizanFormsetState { const { name, initialCount = 1, diff --git a/packages/mizan-react/src/index.ts b/packages/mizan-react/src/index.ts index 7620227..22d39fa 100644 --- a/packages/mizan-react/src/index.ts +++ b/packages/mizan-react/src/index.ts @@ -1,26 +1,16 @@ /** - * mizan - Django Server Functions Client + * mizan — Server Functions Client * - * Frontend client for Django server functions. - * Server functions are the core primitive - accessed via React hooks. + * Frontend client for Mizan server functions. + * Server functions are the core primitive — accessed via React hooks. * * Two-layer architecture: * - * 1. Library layer (this package) - Generic name-based API - * Used by libraries like Allauth that need to call functions by name. - * + * 1. Library layer (this package) — Generic name-based API * import { useMizan, useMizanContext, useMizanCall } from 'mizan' - * const user = useMizanContext('current_user') - * const call = useMizanCall('update_profile') - * - * 2. Generated layer (@/api) - Typed project-specific API - * Used by product code for type-safe hooks. * + * 2. Generated layer (@/api) — Typed project-specific API * import { useCurrentUser, useUpdateProfile } from '@/api' - * const user = useCurrentUser() - * const updateProfile = useUpdateProfile() - * - * The generated code wraps MizanProvider and adds type-safe hooks. */ // ============================================================================ @@ -33,7 +23,7 @@ export { type MizanProviderProps, type MizanHydration, - // Hooks (generic name-based API for libraries) + // Hooks useMizan, useMizanContext, useMizanCall, @@ -47,26 +37,18 @@ export { type PushListener, type ContextStore, type Transport, - - // Legacy aliases (deprecated, for migration) - DjangoContext, - useDjango, - useDjangoStatus, - useServerFunction, - type DjangoContextValue, - type DjangoContextProps, } from './context' // ============================================================================ -// HTTP Client (for SSR or non-React usage) +// HTTP Client // ============================================================================ export { - createDjangoCSRClient, - createDjangoSSRClient, - ensureDjangoSession, + createMizanCSRClient, + createMizanSSRClient, + ensureMizanSession, Auth, - type DjangoHTTPClient, + type MizanHTTPClient, type CSRClientConfig, type JWTClientConfig, type SSRClientConfig, @@ -77,38 +59,68 @@ export { // ============================================================================ export { - DjangoError, + MizanError, type FunctionErrorResponse, type ErrorCode, } from './errors' // ============================================================================ -// Forms (typed form hooks core) +// Forms // ============================================================================ export { - // Single form useMizanFormCore, - // Legacy alias - useMizanFormCore as useDjangoFormCore, - type DjangoFormState, + type MizanFormState, type FormSchema, type FormErrors, type FormOptions, type FormSubmitResult, type FormCoreConfig, - // Formset useMizanFormsetCore, - // Legacy alias - useMizanFormsetCore as useDjangoFormsetCore, - type DjangoFormsetState, + type MizanFormsetState, type FormsetSchema, type FormsetErrors, type FormsetCoreConfig, type FormsetSubmitResult, - // Shared types type FieldSchema, type FieldChoice, type FieldError, type FormMeta, } from './forms' + +// ============================================================================ +// Configuration +// ============================================================================ + +export { configureCsrf } from './utils' + +// ============================================================================ +// Legacy aliases (deprecated) +// ============================================================================ + +export { + // Provider aliases + DjangoContext, + useDjango, + useDjangoStatus, + useServerFunction, + type DjangoContextValue, + type DjangoContextProps, +} from './context' + +export { + // Client aliases + createMizanCSRClient as createDjangoCSRClient, + createMizanSSRClient as createDjangoSSRClient, + ensureMizanSession as ensureDjangoSession, + type MizanHTTPClient as DjangoHTTPClient, +} from './client/' + +export { MizanError as DjangoError } from './errors' + +export { + useMizanFormCore as useDjangoFormCore, + type MizanFormState as DjangoFormState, + useMizanFormsetCore as useDjangoFormsetCore, + type MizanFormsetState as DjangoFormsetState, +} from './forms' diff --git a/packages/mizan-react/src/utils.ts b/packages/mizan-react/src/utils.ts index bb6c566..bce9ca2 100644 --- a/packages/mizan-react/src/utils.ts +++ b/packages/mizan-react/src/utils.ts @@ -2,9 +2,28 @@ * Shared utilities used across mizan-react. */ +/** Default CSRF cookie name. Configurable via MizanProvider. */ +let _csrfCookieName = 'csrftoken' + +/** Default CSRF header name. Configurable via MizanProvider. */ +let _csrfHeaderName = 'X-CSRFToken' + +export function configureCsrf(cookieName: string, headerName: string): void { + _csrfCookieName = cookieName + _csrfHeaderName = headerName +} + +export function getCsrfCookieName(): string { + return _csrfCookieName +} + +export function getCsrfHeaderName(): string { + return _csrfHeaderName +} + /** 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=([^;]+)/) + const match = document.cookie.match(new RegExp(`${_csrfCookieName}=([^;]+)`)) return match?.[1] ?? null }