Remove all Django-specific naming from mizan-react

Renamed:
  DjangoError         → MizanError
  DjangoHTTPClient    → MizanHTTPClient
  DjangoFormState     → MizanFormState
  DjangoFormsetState  → MizanFormsetState
  createDjangoCSRClient → createMizanCSRClient
  createDjangoSSRClient → createMizanSSRClient
  ensureDjangoSession → ensureMizanSession
  useDjangoCSRClient  → useMizanCSRClient
  TDjangoMessage      → TServerMessage

Made CSRF configurable:
  configureCsrf(cookieName, headerName) — defaults to Django
  conventions but works with any backend that uses CSRF tokens.
  Cookie name and header name are no longer hardcoded.

All old names preserved as deprecated aliases in index.ts exports
for backwards compatibility.

Removed dead RouterAdapter re-export (file moved to legacy/).

33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 03:55:24 -04:00
parent 27c30d7e50
commit 1c6d9075ad
12 changed files with 214 additions and 186 deletions

View File

@@ -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(
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent />
</DjangoContext>
</MizanProvider>
)
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(
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent />
</DjangoContext>
</MizanProvider>
)
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(
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent />
</DjangoContext>
</MizanProvider>
)
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<EchoInput, EchoOutput>('echo')
const echo = useMizanCall<EchoInput, EchoOutput>('echo')
React.useEffect(() => {
echo({ text: 'typed function test' })
@@ -227,9 +227,9 @@ describeIntegration('mizan Context (integration)', () => {
}
render(
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent />
</DjangoContext>
</MizanProvider>
)
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(
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent />
</DjangoContext>
</MizanProvider>
)
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(
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent />
</DjangoContext>
</MizanProvider>
)
await waitFor(() => {

View File

@@ -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',

View File

@@ -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)
})

View File

@@ -15,12 +15,12 @@ import type {
IncomingPayload,
} from './types'
export interface UseChannelOptions<TDjangoMessage> {
export interface UseChannelOptions<TServerMessage> {
/** 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<TDjangoMessage> {
*/
export function useChannel<
TParams = undefined,
TDjangoMessage = unknown,
TServerMessage = unknown,
TReactMessage = unknown,
>(
channelName: string,
params?: TParams,
options: UseChannelOptions<TDjangoMessage> = {},
): ChannelSubscription<TParams, TDjangoMessage, TReactMessage> {
options: UseChannelOptions<TServerMessage> = {},
): ChannelSubscription<TParams, TServerMessage, TReactMessage> {
const { connection, status: connectionStatus } = useChannelContext()
const [messages, setMessages] = useState<TDjangoMessage[]>([])
const [messages, setMessages] = useState<TServerMessage[]>([])
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<TParams, TDjangoMessage, TReactMessage>['send'],
send: send as ChannelSubscription<TParams, TServerMessage, TReactMessage>['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<TDjangoMessage> = {},
): Omit<ChannelSubscription<TParams, TDjangoMessage, TReactMessage>, 'messages'> & { latest: TDjangoMessage | null } {
const [latest, setLatest] = useState<TDjangoMessage | null>(null)
options: UseChannelOptions<TServerMessage> = {},
): Omit<ChannelSubscription<TParams, TServerMessage, TReactMessage>, 'messages'> & { latest: TServerMessage | null } {
const [latest, setLatest] = useState<TServerMessage | null>(null)
const channel = useChannel<TParams, TDjangoMessage, TReactMessage>(
const channel = useChannel<TParams, TServerMessage, TReactMessage>(
channelName,
params,
{

View File

@@ -4,12 +4,12 @@
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected'
export interface ChannelSubscription<TParams = unknown, TDjangoMessage = unknown, TReactMessage = unknown> {
export interface ChannelSubscription<TParams = unknown, TServerMessage = unknown, TReactMessage = unknown> {
/** 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

View File

@@ -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<HttpEr
*
* @param auth - Authentication strategy (Auth.SESSION or Auth.JWT)
* @param config - Client configuration
* @returns DjangoHTTPClient
* @returns MizanHTTPClient
*
* @example
* // Session-based
* const client = createDjangoCSRClient(Auth.SESSION)
* const client = createMizanCSRClient(Auth.SESSION)
*
* @example
* // JWT-based
* const client = createDjangoCSRClient(Auth.JWT, {
* const client = createMizanCSRClient(Auth.JWT, {
* getAccessToken: async () => 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<string, string> = {
'Accept': 'application/json',
'X-CSRFToken': csrf,
[getCsrfHeaderName()]: csrf,
'Cookie': cookieHeader,
...headers,
}
@@ -390,7 +387,7 @@ export function createDjangoSSRClient(config: SSRClientConfig): DjangoHTTPClient
const requestHeaders: Record<string, string> = {
'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

View File

@@ -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])
}

View File

@@ -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<TInput, TOutput>(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

View File

@@ -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)
}
}

View File

@@ -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<TData extends Record<string, unknown> = Record<s
/**
* Typed form state returned by form hooks.
*/
export interface DjangoFormState<TData extends Record<string, unknown>> {
export interface MizanFormState<TData extends Record<string, unknown>> {
/** Current form data - typed by TData */
data: TData
@@ -354,7 +354,7 @@ export interface FormsetErrors<TData extends Record<string, unknown>> {
/**
* Typed formset state returned by formset hooks.
*/
export interface DjangoFormsetState<TData extends Record<string, unknown>> {
export interface MizanFormsetState<TData extends Record<string, unknown>> {
/** Array of form data objects - each typed by TData */
forms: TData[]
@@ -449,7 +449,7 @@ export interface FormCoreConfig<TData extends Record<string, unknown>> {
*/
export function useMizanFormCore<TData extends Record<string, unknown>>(
config: FormCoreConfig<TData>
): DjangoFormState<TData> {
): MizanFormState<TData> {
const { name, zodSchema, options = {} } = config
const {
liveValidation: liveValidationOption = 'field-only',
@@ -768,8 +768,8 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
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<TData extends Record<string, unknown>> {
*/
export function useMizanFormsetCore<TData extends Record<string, unknown>>(
config: FormsetCoreConfig<TData>
): DjangoFormsetState<TData> {
): MizanFormsetState<TData> {
const {
name,
initialCount = 1,

View File

@@ -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'

View File

@@ -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
}