Restructure tree by role; rename mizan-runtime → mizan-base
packages/ flattens into: backends/ server protocol adapters (mizan-django, mizan-ts) frontends/ client kernel + framework adapters (mizan-base, mizan-react, mizan-vue, mizan-svelte) workers/ runtime workers (mizan-ssr) cores/ shared language-level primitives (empty for now; mizan-python forthcoming) The frontend kernel (was packages/mizan-runtime, now frontends/mizan-base) is renamed to reflect its role — it's the shared base that frontend adapters depend on directly. Reflects the substrate position that per-framework adapters wrap a single shared kernel; codegen targets the adapter, not the raw kernel. Path updates landed in: Makefile, two Gitea workflows, Dockerfile.test, four example/harness config files, .claude/settings.local.json, four docs (CLAUDE/ISSUES/ROADMAP/AFI_ARCHITECTURE), four codegen templates (stage1 + react/vue/svelte adapters), and three package.jsons (the mizan-base rename plus mizan-vue/svelte peerDeps). Generated files under examples/django-react-site/harness/src/api/ still reference @mizan/runtime — left as-is; they're regenerated artifacts and the harness is non-functional pending the React wrapper-layer codegen. Also folded in a pre-existing fix: the Gitea workflows had working-directory: react / django pointing at a layout that predates packages/, never updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
314
frontends/mizan-react/src/__tests__/context.test.tsx
Normal file
314
frontends/mizan-react/src/__tests__/context.test.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Tests for Django Server React Context
|
||||
*
|
||||
* Unit tests run without backend.
|
||||
* Integration tests require: docker-compose up
|
||||
*
|
||||
* Run integration tests with: RUN_INTEGRATION_TESTS=true npm run test
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, act } from '@testing-library/react'
|
||||
import {
|
||||
MizanProvider,
|
||||
useMizan,
|
||||
useMizanStatus,
|
||||
useMizanCall,
|
||||
// Legacy aliases for backwards compatibility tests
|
||||
MizanProvider,
|
||||
useDjango,
|
||||
useMizanStatus,
|
||||
useMizanCall,
|
||||
} from '../context'
|
||||
import { MizanError } from '../errors'
|
||||
import { describeIntegration, BACKEND_URL } from '../testing'
|
||||
|
||||
// ============================================================================
|
||||
// Unit Tests (no backend required)
|
||||
// ============================================================================
|
||||
|
||||
describe('mizan Context (unit)', () => {
|
||||
describe('useMizan hook', () => {
|
||||
it('should throw when used outside provider', () => {
|
||||
function TestComponent() {
|
||||
useMizan()
|
||||
return <div>Test</div>
|
||||
}
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
|
||||
|
||||
expect(() => render(<TestComponent />)).toThrow(
|
||||
'useMizan must be used within a MizanProvider'
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should return context value inside provider', () => {
|
||||
let contextValue: any = null
|
||||
|
||||
function TestComponent() {
|
||||
contextValue = useMizan()
|
||||
return <div>Test</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<MizanProvider autoConnect={false}>
|
||||
<TestComponent />
|
||||
</MizanProvider>
|
||||
)
|
||||
|
||||
expect(contextValue).not.toBeNull()
|
||||
expect(contextValue!.status).toBe('disconnected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMizanStatus hook', () => {
|
||||
it('should return disconnected when autoConnect is false', () => {
|
||||
function TestComponent() {
|
||||
const status = useMizanStatus()
|
||||
return <div data-testid="status">{status}</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<MizanProvider autoConnect={false}>
|
||||
<TestComponent />
|
||||
</MizanProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('status')).toHaveTextContent('disconnected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hydration', () => {
|
||||
it('should initialize context store from hydration data', () => {
|
||||
let contextValue: any = null
|
||||
|
||||
function TestComponent() {
|
||||
contextValue = useMizan()
|
||||
return <div>Test</div>
|
||||
}
|
||||
|
||||
const hydration = {
|
||||
auth_status: { is_authenticated: false },
|
||||
user: null,
|
||||
}
|
||||
|
||||
render(
|
||||
<MizanProvider hydration={hydration} autoConnect={false}>
|
||||
<TestComponent />
|
||||
</MizanProvider>
|
||||
)
|
||||
|
||||
expect(contextValue.getContext('auth_status')).toEqual({ is_authenticated: false })
|
||||
expect(contextValue.getContext('user')).toEqual(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Integration Tests (require running backend)
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('mizan Context (integration)', () => {
|
||||
describe('server function calls via HTTP', () => {
|
||||
it('should call echo function and get response', async () => {
|
||||
let result: any = null
|
||||
let error: any = null
|
||||
|
||||
function TestComponent() {
|
||||
const { call, status } = useMizan()
|
||||
|
||||
React.useEffect(() => {
|
||||
// Use HTTP fallback (status will be disconnected without WebSocket)
|
||||
call<{ text: string }, { message: string }>('echo', { text: 'context test' })
|
||||
.then((r) => { result = r })
|
||||
.catch((e) => { error = e })
|
||||
}, [call])
|
||||
|
||||
return <div data-testid="status">{status}</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
<TestComponent />
|
||||
</MizanProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result || error).not.toBeNull()
|
||||
}, { timeout: 5000 })
|
||||
|
||||
expect(error).toBeNull()
|
||||
expect(result).toHaveProperty('message')
|
||||
expect(result.message).toContain('context test')
|
||||
})
|
||||
|
||||
it('should call add function with correct result', async () => {
|
||||
let result: any = null
|
||||
let error: any = null
|
||||
|
||||
function TestComponent() {
|
||||
const { call } = useMizan()
|
||||
|
||||
React.useEffect(() => {
|
||||
call<{ a: number; b: number }, { result: number }>('add', { a: 10, b: 20 })
|
||||
.then((r) => { result = r })
|
||||
.catch((e) => { error = e })
|
||||
}, [call])
|
||||
|
||||
return <div>Test</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
<TestComponent />
|
||||
</MizanProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result || error).not.toBeNull()
|
||||
}, { timeout: 5000 })
|
||||
|
||||
expect(error).toBeNull()
|
||||
expect(result).toEqual({ result: 30 })
|
||||
})
|
||||
|
||||
it('should throw MizanError for validation errors', async () => {
|
||||
let result: any = null
|
||||
let error: any = null
|
||||
|
||||
function TestComponent() {
|
||||
const { call } = useMizan()
|
||||
|
||||
React.useEffect(() => {
|
||||
// Call without required field
|
||||
call('echo', {})
|
||||
.then((r) => { result = r })
|
||||
.catch((e) => { error = e })
|
||||
}, [call])
|
||||
|
||||
return <div>Test</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
<TestComponent />
|
||||
</MizanProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result || error).not.toBeNull()
|
||||
}, { timeout: 5000 })
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(error).toBeInstanceOf(MizanError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMizanCall hook', () => {
|
||||
it('should create typed function that calls backend', async () => {
|
||||
let result: any = null
|
||||
let error: any = null
|
||||
|
||||
interface EchoInput { text: string }
|
||||
interface EchoOutput { message: string }
|
||||
|
||||
function TestComponent() {
|
||||
const echo = useMizanCall<EchoInput, EchoOutput>('echo')
|
||||
|
||||
React.useEffect(() => {
|
||||
echo({ text: 'typed function test' })
|
||||
.then((r) => { result = r })
|
||||
.catch((e) => { error = e })
|
||||
}, [echo])
|
||||
|
||||
return <div>Test</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
<TestComponent />
|
||||
</MizanProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result || error).not.toBeNull()
|
||||
}, { timeout: 5000 })
|
||||
|
||||
expect(error).toBeNull()
|
||||
expect(result).toHaveProperty('message')
|
||||
expect(result.message).toContain('typed function test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('form functions', () => {
|
||||
it('should call login.schema and get form fields', async () => {
|
||||
let result: any = null
|
||||
let error: any = null
|
||||
|
||||
function TestComponent() {
|
||||
const { call } = useMizan()
|
||||
|
||||
React.useEffect(() => {
|
||||
call('login.schema', { data: {} })
|
||||
.then((r) => { result = r })
|
||||
.catch((e) => { error = e })
|
||||
}, [call])
|
||||
|
||||
return <div>Test</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
<TestComponent />
|
||||
</MizanProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result || error).not.toBeNull()
|
||||
}, { timeout: 5000 })
|
||||
|
||||
expect(error).toBeNull()
|
||||
expect(result).toHaveProperty('fields')
|
||||
expect(result).toHaveProperty('meta')
|
||||
// Login form should have login and password fields
|
||||
expect(result.fields).toHaveProperty('login')
|
||||
expect(result.fields).toHaveProperty('password')
|
||||
})
|
||||
|
||||
it('should call login.validate and get validation result', async () => {
|
||||
let result: any = null
|
||||
let error: any = null
|
||||
|
||||
function TestComponent() {
|
||||
const { call } = useMizan()
|
||||
|
||||
React.useEffect(() => {
|
||||
call('login.validate', {
|
||||
data: { login: 'test@example.com', password: 'testpass' }
|
||||
})
|
||||
.then((r) => { result = r })
|
||||
.catch((e) => { error = e })
|
||||
}, [call])
|
||||
|
||||
return <div>Test</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
<TestComponent />
|
||||
</MizanProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result || error).not.toBeNull()
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Should return validation result (may have errors for invalid creds, that's ok)
|
||||
expect(error).toBeNull()
|
||||
expect(result).toHaveProperty('valid')
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
214
frontends/mizan-react/src/__tests__/errors.test.ts
Normal file
214
frontends/mizan-react/src/__tests__/errors.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Tests for Django Server Error
|
||||
*/
|
||||
|
||||
import { MizanError, type FunctionErrorResponse } from '../errors'
|
||||
|
||||
describe('MizanError', () => {
|
||||
it('should create error with message and code', () => {
|
||||
const response: FunctionErrorResponse = {
|
||||
error: true,
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Function not found',
|
||||
}
|
||||
|
||||
const error = new MizanError(response)
|
||||
|
||||
expect(error.message).toBe('Function not found')
|
||||
expect(error.code).toBe('NOT_FOUND')
|
||||
expect(error.name).toBe('MizanError')
|
||||
})
|
||||
|
||||
it('should preserve details', () => {
|
||||
const response: FunctionErrorResponse = {
|
||||
error: true,
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid input',
|
||||
details: {
|
||||
fields: {
|
||||
name: ['Required', 'Too short'],
|
||||
email: ['Invalid format'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const error = new MizanError(response)
|
||||
|
||||
expect(error.details).toBeDefined()
|
||||
expect(error.details?.fields?.name).toEqual(['Required', 'Too short'])
|
||||
})
|
||||
|
||||
it('should preserve original response', () => {
|
||||
const response: FunctionErrorResponse = {
|
||||
error: true,
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Server error',
|
||||
}
|
||||
|
||||
const error = new MizanError(response)
|
||||
|
||||
expect(error.response).toBe(response)
|
||||
})
|
||||
|
||||
describe('isValidationError', () => {
|
||||
it('should return true for validation errors', () => {
|
||||
const error = new MizanError({
|
||||
error: true,
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid',
|
||||
})
|
||||
|
||||
expect(error.isValidationError()).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for other errors', () => {
|
||||
const error = new MizanError({
|
||||
error: true,
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Not found',
|
||||
})
|
||||
|
||||
expect(error.isValidationError()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAuthError', () => {
|
||||
it('should return true for unauthorized', () => {
|
||||
const error = new MizanError({
|
||||
error: true,
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Not authenticated',
|
||||
})
|
||||
|
||||
expect(error.isAuthError()).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for forbidden', () => {
|
||||
const error = new MizanError({
|
||||
error: true,
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Access denied',
|
||||
})
|
||||
|
||||
expect(error.isAuthError()).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for other errors', () => {
|
||||
const error = new MizanError({
|
||||
error: true,
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Not found',
|
||||
})
|
||||
|
||||
expect(error.isAuthError()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNotFound', () => {
|
||||
it('should return true for not found errors', () => {
|
||||
const error = new MizanError({
|
||||
error: true,
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Not found',
|
||||
})
|
||||
|
||||
expect(error.isNotFound()).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for other errors', () => {
|
||||
const error = new MizanError({
|
||||
error: true,
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid',
|
||||
})
|
||||
|
||||
expect(error.isNotFound()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFieldErrors', () => {
|
||||
it('should return field errors for validation error', () => {
|
||||
const error = new MizanError({
|
||||
error: true,
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid input',
|
||||
details: {
|
||||
fields: {
|
||||
name: ['Required'],
|
||||
email: ['Invalid'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const errors = error.getFieldErrors()
|
||||
|
||||
expect(errors).toEqual({
|
||||
name: ['Required'],
|
||||
email: ['Invalid'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null for non-validation errors', () => {
|
||||
const error = new MizanError({
|
||||
error: true,
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Not found',
|
||||
})
|
||||
|
||||
expect(error.getFieldErrors()).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null if no fields in details', () => {
|
||||
const error = new MizanError({
|
||||
error: true,
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid',
|
||||
details: {},
|
||||
})
|
||||
|
||||
expect(error.getFieldErrors()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFieldError', () => {
|
||||
it('should return first error for a field', () => {
|
||||
const error = new MizanError({
|
||||
error: true,
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid input',
|
||||
details: {
|
||||
fields: {
|
||||
name: ['Required', 'Too short'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(error.getFieldError('name')).toBe('Required')
|
||||
})
|
||||
|
||||
it('should return null for non-existent field', () => {
|
||||
const error = new MizanError({
|
||||
error: true,
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid input',
|
||||
details: {
|
||||
fields: {
|
||||
name: ['Required'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(error.getFieldError('email')).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for non-validation errors', () => {
|
||||
const error = new MizanError({
|
||||
error: true,
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Not found',
|
||||
})
|
||||
|
||||
expect(error.getFieldError('name')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
362
frontends/mizan-react/src/__tests__/forms.test.tsx
Normal file
362
frontends/mizan-react/src/__tests__/forms.test.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Tests for Django Forms
|
||||
*
|
||||
* Integration tests call the REAL backend - no mocks.
|
||||
* Backend must be running: docker-compose up
|
||||
*
|
||||
* Run integration tests with: RUN_INTEGRATION_TESTS=true npm run test
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
useDjangoFormCore,
|
||||
type FormCoreConfig,
|
||||
} from '../forms'
|
||||
import { DjangoContext } from '../context'
|
||||
import { describeIntegration, BACKEND_URL } from '../testing'
|
||||
|
||||
// ============================================================================
|
||||
// Test Setup
|
||||
// ============================================================================
|
||||
|
||||
// Helper to render hook with provider
|
||||
function renderFormHook<TData extends Record<string, unknown>>(
|
||||
config: FormCoreConfig<TData>
|
||||
) {
|
||||
return renderHook(() => useDjangoFormCore<TData>(config), {
|
||||
wrapper: ({ children }) => (
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
{children}
|
||||
</DjangoContext>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Integration Tests - Real Backend Calls
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('useDjangoFormCore (integration)', () => {
|
||||
describe('Schema loading from real backend', () => {
|
||||
it('loads login form schema', async () => {
|
||||
const { result } = renderFormHook({
|
||||
name: 'login',
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
expect(result.current.schema).not.toBeNull()
|
||||
expect(result.current.schema?.name).toBe('login')
|
||||
// Login form should have login and password fields
|
||||
expect(result.current.schema?.fields).toHaveProperty('login')
|
||||
expect(result.current.schema?.fields).toHaveProperty('password')
|
||||
})
|
||||
|
||||
it('loads signup form schema', async () => {
|
||||
const { result } = renderFormHook({
|
||||
name: 'signup',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
expect(result.current.schema).not.toBeNull()
|
||||
expect(result.current.schema?.name).toBe('signup')
|
||||
// Signup form should have email and password fields
|
||||
expect(result.current.schema?.fields).toHaveProperty('email')
|
||||
expect(result.current.schema?.fields).toHaveProperty('password1')
|
||||
})
|
||||
|
||||
it('loads add_email form schema', async () => {
|
||||
const { result } = renderFormHook({
|
||||
name: 'add_email',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
expect(result.current.schema).not.toBeNull()
|
||||
expect(result.current.schema?.fields).toHaveProperty('email')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form data management', () => {
|
||||
it('sets and gets form data', async () => {
|
||||
const { result } = renderFormHook({
|
||||
name: 'login',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
act(() => {
|
||||
result.current.set('login', 'test@example.com')
|
||||
result.current.set('password', 'testpassword123')
|
||||
})
|
||||
|
||||
expect(result.current.data.login).toBe('test@example.com')
|
||||
expect(result.current.data.password).toBe('testpassword123')
|
||||
})
|
||||
|
||||
it('tracks touched fields', async () => {
|
||||
const { result } = renderFormHook({
|
||||
name: 'login',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
expect(result.current.touchedFields.size).toBe(0)
|
||||
|
||||
act(() => {
|
||||
result.current.touch('login')
|
||||
})
|
||||
|
||||
expect(result.current.touchedFields.has('login')).toBe(true)
|
||||
expect(result.current.touchedFields.has('password')).toBe(false)
|
||||
})
|
||||
|
||||
it('resets form state', async () => {
|
||||
const { result } = renderFormHook({
|
||||
name: 'login',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
act(() => {
|
||||
result.current.set('login', 'changed@example.com')
|
||||
result.current.touch('login')
|
||||
})
|
||||
|
||||
expect(result.current.data.login).toBe('changed@example.com')
|
||||
expect(result.current.touchedFields.has('login')).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.reset()
|
||||
})
|
||||
|
||||
expect(result.current.data.login).toBe('')
|
||||
expect(result.current.touchedFields.size).toBe(0)
|
||||
expect(result.current.errors).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Zod validation with real schema', () => {
|
||||
// Define Zod schema matching login form
|
||||
const LoginZodSchema = z.object({
|
||||
login: z.string().min(1, 'Login is required').email('Invalid email'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
})
|
||||
|
||||
type LoginData = z.infer<typeof LoginZodSchema>
|
||||
|
||||
it('validates with Zod schema on touch', async () => {
|
||||
const { result } = renderFormHook<LoginData>({
|
||||
name: 'login',
|
||||
zodSchema: LoginZodSchema,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Set invalid value
|
||||
act(() => {
|
||||
result.current.set('login', 'not-an-email')
|
||||
})
|
||||
|
||||
// Touch triggers validation
|
||||
act(() => {
|
||||
result.current.touch('login')
|
||||
})
|
||||
|
||||
// Zod validation should show email format error
|
||||
expect(result.current.errors?.fields.login).toBeDefined()
|
||||
expect(result.current.errors?.fields.login?.[0]?.message).toBe('Invalid email')
|
||||
expect(result.current.errors?.fields.login?.[0]?.source).toBe('zod')
|
||||
})
|
||||
|
||||
it('clears errors when value becomes valid', async () => {
|
||||
const { result } = renderFormHook<LoginData>({
|
||||
name: 'login',
|
||||
zodSchema: LoginZodSchema,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Set invalid value and touch
|
||||
act(() => {
|
||||
result.current.set('login', 'bad')
|
||||
})
|
||||
act(() => {
|
||||
result.current.touch('login')
|
||||
})
|
||||
|
||||
expect(result.current.errors?.fields.login).toBeDefined()
|
||||
|
||||
// Set valid value and touch
|
||||
act(() => {
|
||||
result.current.set('login', 'valid@example.com')
|
||||
})
|
||||
act(() => {
|
||||
result.current.touch('login')
|
||||
})
|
||||
|
||||
expect(result.current.errors?.fields.login).toBeUndefined()
|
||||
})
|
||||
|
||||
it('tracks hasErrors correctly', async () => {
|
||||
const { result } = renderFormHook<LoginData>({
|
||||
name: 'login',
|
||||
zodSchema: LoginZodSchema,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
expect(result.current.hasErrors).toBe(false)
|
||||
|
||||
// Set invalid and touch
|
||||
act(() => {
|
||||
result.current.set('login', 'bad')
|
||||
})
|
||||
act(() => {
|
||||
result.current.touch('login')
|
||||
})
|
||||
|
||||
expect(result.current.hasErrors).toBe(true)
|
||||
|
||||
// Set valid and touch
|
||||
act(() => {
|
||||
result.current.set('login', 'good@example.com')
|
||||
})
|
||||
act(() => {
|
||||
result.current.touch('login')
|
||||
})
|
||||
|
||||
expect(result.current.hasErrors).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form submission', () => {
|
||||
it('submits login form and handles validation errors', async () => {
|
||||
const { result } = renderFormHook({
|
||||
name: 'login',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Set invalid credentials
|
||||
act(() => {
|
||||
result.current.set('login', 'nonexistent@example.com')
|
||||
result.current.set('password', 'wrongpassword')
|
||||
})
|
||||
|
||||
// Submit should fail with validation error
|
||||
let submitResult: any
|
||||
await act(async () => {
|
||||
submitResult = await result.current.submit()
|
||||
})
|
||||
|
||||
// Submit should return error (invalid credentials)
|
||||
// The exact error depends on backend behavior
|
||||
expect(submitResult).toBeDefined()
|
||||
})
|
||||
|
||||
it('submits signup form with missing required fields', async () => {
|
||||
const { result } = renderFormHook({
|
||||
name: 'signup',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Submit with empty fields should return validation errors
|
||||
let submitResult: any
|
||||
await act(async () => {
|
||||
submitResult = await result.current.submit()
|
||||
})
|
||||
|
||||
// Should have validation errors for required fields
|
||||
expect(submitResult).toBeDefined()
|
||||
expect(submitResult.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error source tagging', () => {
|
||||
const LoginZodSchema = z.object({
|
||||
login: z.string().min(1, 'Login is required').email('Invalid email'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
})
|
||||
|
||||
type LoginData = z.infer<typeof LoginZodSchema>
|
||||
|
||||
it('tags Zod errors with source: zod', async () => {
|
||||
const { result } = renderFormHook<LoginData>({
|
||||
name: 'login',
|
||||
zodSchema: LoginZodSchema,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
act(() => {
|
||||
result.current.set('login', 'invalid')
|
||||
})
|
||||
act(() => {
|
||||
result.current.touch('login')
|
||||
})
|
||||
|
||||
const errors = result.current.getFieldErrors('login')
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors[0].source).toBe('zod')
|
||||
})
|
||||
|
||||
it('filters errors by source', async () => {
|
||||
const { result } = renderFormHook<LoginData>({
|
||||
name: 'login',
|
||||
zodSchema: LoginZodSchema,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
act(() => {
|
||||
result.current.set('login', 'invalid')
|
||||
})
|
||||
act(() => {
|
||||
result.current.touch('login')
|
||||
})
|
||||
|
||||
// Should have Zod errors
|
||||
const zodErrors = result.current.getFieldErrors('login', { source: 'zod' })
|
||||
expect(zodErrors.length).toBeGreaterThan(0)
|
||||
|
||||
// Should have no server errors yet
|
||||
const serverErrors = result.current.getFieldErrors('login', { source: 'server' })
|
||||
expect(serverErrors.length).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
824
frontends/mizan-react/src/__tests__/integration.test.tsx
Normal file
824
frontends/mizan-react/src/__tests__/integration.test.tsx
Normal file
@@ -0,0 +1,824 @@
|
||||
/**
|
||||
* Cross-cutting integration tests for mizan
|
||||
*
|
||||
* Tests error paths and protocol correctness across HTTP, Forms, and WebSocket.
|
||||
* Requires a running backend: docker-compose up
|
||||
*
|
||||
* Run with: RUN_INTEGRATION_TESTS=true npm run test
|
||||
*/
|
||||
|
||||
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 { MizanError } from '../errors'
|
||||
import { ChannelConnection } from '../channels/connection'
|
||||
import { RPCError } from '../channels/connection'
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
{children}
|
||||
</MizanProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to get call function
|
||||
function useCall() {
|
||||
const { call } = useMizan()
|
||||
return call
|
||||
}
|
||||
|
||||
// Helper to wait for a ChannelConnection to reach 'connected' status
|
||||
function waitForConnected(connection: ChannelConnection): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (connection.status === 'connected') { resolve(); return }
|
||||
const unsub = connection.onStatusChange((status) => {
|
||||
if (status === 'connected') { unsub(); resolve() }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Group 1: Executor framework validation
|
||||
// ============================================================================
|
||||
|
||||
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: MizanError | null = null
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current('add', { a: 'hello', b: 'world' })
|
||||
} catch (e) {
|
||||
error = e as MizanError
|
||||
}
|
||||
})
|
||||
|
||||
expect(error).toBeInstanceOf(MizanError)
|
||||
expect(error!.code).toBe('VALIDATION_ERROR')
|
||||
expect(error!.isValidationError()).toBe(true)
|
||||
const fieldErrors = error!.getFieldErrors()
|
||||
expect(fieldErrors).not.toBeNull()
|
||||
expect(Object.keys(fieldErrors!).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should return NOT_FOUND for non-existent function', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let error: MizanError | null = null
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current('this_function_does_not_exist', {})
|
||||
} catch (e) {
|
||||
error = e as MizanError
|
||||
}
|
||||
})
|
||||
|
||||
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: MizanError | null = null
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current('whoami', {})
|
||||
} catch (e) {
|
||||
error = e as MizanError
|
||||
}
|
||||
})
|
||||
|
||||
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: MizanError | null = null
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current('echo', {})
|
||||
} catch (e) {
|
||||
error = e as MizanError
|
||||
}
|
||||
})
|
||||
|
||||
expect(error).toBeInstanceOf(MizanError)
|
||||
expect(error!.code).toBe('VALIDATION_ERROR')
|
||||
const fieldErrors = error!.getFieldErrors()
|
||||
expect(fieldErrors).not.toBeNull()
|
||||
expect(fieldErrors!).toHaveProperty('text')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 2: Form framework validation
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('Form framework validation', () => {
|
||||
it('should return field metadata with types and required flags', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('login.schema', { data: {} })
|
||||
})
|
||||
|
||||
expect(response).toHaveProperty('fields')
|
||||
|
||||
// Each field should have name, label, type, required, widget
|
||||
const fields = response.fields
|
||||
for (const fieldKey of Object.keys(fields)) {
|
||||
const field = fields[fieldKey]
|
||||
expect(field).toHaveProperty('name')
|
||||
expect(field).toHaveProperty('label')
|
||||
expect(field).toHaveProperty('type')
|
||||
expect(field).toHaveProperty('required')
|
||||
expect(field).toHaveProperty('widget')
|
||||
}
|
||||
|
||||
// login field should be required
|
||||
expect(fields.login.required).toBe(true)
|
||||
|
||||
// password field widget should contain 'password'
|
||||
expect(fields.password.widget.toLowerCase()).toContain('password')
|
||||
})
|
||||
|
||||
it('should return field-level errors for empty form validation', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('login.validate', { data: {} })
|
||||
})
|
||||
|
||||
expect(response.valid).toBe(false)
|
||||
expect(response.errors).toBeInstanceOf(Array)
|
||||
expect(response.errors.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should return form-level error for wrong login credentials', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('login.submit', {
|
||||
login: 'wrong@example.com',
|
||||
password: 'wrongpass',
|
||||
})
|
||||
})
|
||||
|
||||
expect(response.success).toBe(false)
|
||||
expect(JSON.stringify(response.errors)).toContain('Invalid login credentials')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 3: WebSocket framework validation
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('WebSocket framework validation', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeEach(async () => {
|
||||
connection = new ChannelConnection({ url: WS_URL, reconnect: false })
|
||||
connection.connect()
|
||||
|
||||
// Wait for connected status
|
||||
await new Promise<void>((resolve) => {
|
||||
const unsub = connection.onStatusChange((status) => {
|
||||
if (status === 'connected') {
|
||||
unsub()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
if (connection.status === 'connected') {
|
||||
unsub()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
it('should deliver messages back through channel subscription', async () => {
|
||||
// Subscribe to chat channel and wait for confirmation
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Subscribe timeout')), 5000)
|
||||
const unsub = connection.onMessage((msg) => {
|
||||
if ('subscribed' in msg && msg.channel === 'chat') {
|
||||
clearTimeout(timeout)
|
||||
unsub()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
connection.send({
|
||||
action: 'subscribe',
|
||||
channel: 'chat',
|
||||
params: { room: 'integration-test' },
|
||||
})
|
||||
})
|
||||
|
||||
// Listen for the echoed message
|
||||
const messagePromise = new Promise<any>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Message timeout')), 5000)
|
||||
const unsub = connection.onMessage((msg) => {
|
||||
if ('data' in msg) {
|
||||
clearTimeout(timeout)
|
||||
unsub()
|
||||
resolve(msg)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Send a message
|
||||
connection.send({
|
||||
action: 'message',
|
||||
channel: 'chat',
|
||||
params: { room: 'integration-test' },
|
||||
data: { text: 'hello from integration test' },
|
||||
})
|
||||
|
||||
const received = await messagePromise
|
||||
expect(received.data).toHaveProperty('text')
|
||||
expect(received.data.text).toBe('hello from integration test')
|
||||
})
|
||||
|
||||
it('should return error for unknown channel subscription', async () => {
|
||||
const errorPromise = new Promise<any>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Error response timeout')), 5000)
|
||||
const unsub = connection.onMessage((msg) => {
|
||||
if ('error' in msg) {
|
||||
clearTimeout(timeout)
|
||||
unsub()
|
||||
resolve(msg)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
connection.send({
|
||||
action: 'subscribe',
|
||||
channel: 'nonexistent_channel',
|
||||
params: {},
|
||||
})
|
||||
|
||||
const errorMsg = await errorPromise
|
||||
expect(errorMsg.error.toLowerCase()).toContain('unknown channel')
|
||||
})
|
||||
|
||||
it('should reject HTTP-only function via WebSocket RPC', async () => {
|
||||
let rpcError: RPCError | null = null
|
||||
try {
|
||||
await connection.rpc('http_only_echo', { text: 'test' })
|
||||
} catch (e) {
|
||||
rpcError = e as RPCError
|
||||
}
|
||||
|
||||
expect(rpcError).toBeInstanceOf(RPCError)
|
||||
})
|
||||
|
||||
it('should return NOT_FOUND for non-existent RPC function', async () => {
|
||||
let rpcError: RPCError | null = null
|
||||
try {
|
||||
await connection.rpc('does_not_exist', {})
|
||||
} catch (e) {
|
||||
rpcError = e as RPCError
|
||||
}
|
||||
|
||||
expect(rpcError).toBeInstanceOf(RPCError)
|
||||
expect(rpcError!.code).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('should return VALIDATION_ERROR for wrong RPC input types', async () => {
|
||||
let rpcError: RPCError | null = null
|
||||
try {
|
||||
await connection.rpc('add', { a: 'not_number', b: 'also_not' })
|
||||
} catch (e) {
|
||||
rpcError = e as RPCError
|
||||
}
|
||||
|
||||
expect(rpcError).toBeInstanceOf(RPCError)
|
||||
expect(rpcError!.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 4: HTTP happy path
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('HTTP happy path', () => {
|
||||
it('should call echo and receive echoed text', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('echo', { text: 'hello world' })
|
||||
})
|
||||
|
||||
expect(response).toHaveProperty('message')
|
||||
expect(response.message).toContain('hello world')
|
||||
})
|
||||
|
||||
it('should call add and receive correct sum', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('add', { a: 17, b: 25 })
|
||||
})
|
||||
|
||||
expect(response).toEqual({ result: 42 })
|
||||
})
|
||||
|
||||
it('should call class-based ServerFunction (multiply)', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('multiply', { x: 7, y: 6 })
|
||||
})
|
||||
|
||||
expect(response).toEqual({ product: 42 })
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 5: Auth variations
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('Auth variations', () => {
|
||||
it('should reject staff_only for anonymous with UNAUTHORIZED', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let error: MizanError | null = null
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current('staff_only', {})
|
||||
} catch (e) {
|
||||
error = e as MizanError
|
||||
}
|
||||
})
|
||||
|
||||
expect(error).toBeInstanceOf(MizanError)
|
||||
expect(error!.code).toBe('UNAUTHORIZED')
|
||||
expect(error!.isAuthError()).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject superuser_only for anonymous with UNAUTHORIZED', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let error: MizanError | null = null
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current('superuser_only', {})
|
||||
} catch (e) {
|
||||
error = e as MizanError
|
||||
}
|
||||
})
|
||||
|
||||
expect(error).toBeInstanceOf(MizanError)
|
||||
expect(error!.code).toBe('UNAUTHORIZED')
|
||||
expect(error!.isAuthError()).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject verified_only for anonymous (callable auth)', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let error: MizanError | null = null
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current('verified_only', {})
|
||||
} catch (e) {
|
||||
error = e as MizanError
|
||||
}
|
||||
})
|
||||
|
||||
// Callable auth returns False for anonymous, which maps to FORBIDDEN
|
||||
expect(error).toBeInstanceOf(MizanError)
|
||||
expect(error!.code).toBe('FORBIDDEN')
|
||||
expect(error!.isAuthError()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 6: Context functions
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('Context functions', () => {
|
||||
it('should call global context current_user and get anonymous response', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('current_user', {})
|
||||
})
|
||||
|
||||
expect(response).toHaveProperty('authenticated', false)
|
||||
expect(response).toHaveProperty('email', '')
|
||||
expect(response).toHaveProperty('is_staff', false)
|
||||
})
|
||||
|
||||
it('should call local context greet with name parameter', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('greet', { name: 'World' })
|
||||
})
|
||||
|
||||
expect(response).toHaveProperty('greeting', 'Hello, World!')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 7: Error code coverage
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('Error code coverage', () => {
|
||||
it('should return NOT_IMPLEMENTED for NotImplementedError', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let error: MizanError | null = null
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current('not_implemented_fn', {})
|
||||
} catch (e) {
|
||||
error = e as MizanError
|
||||
}
|
||||
})
|
||||
|
||||
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: MizanError | null = null
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current('buggy_fn', {})
|
||||
} catch (e) {
|
||||
error = e as MizanError
|
||||
}
|
||||
})
|
||||
|
||||
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: MizanError | null = null
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current('permission_check_fn', { secret: 'wrong' })
|
||||
} catch (e) {
|
||||
error = e as MizanError
|
||||
}
|
||||
})
|
||||
|
||||
expect(error).toBeInstanceOf(MizanError)
|
||||
expect(error!.code).toBe('FORBIDDEN')
|
||||
expect(error!.isAuthError()).toBe(true)
|
||||
})
|
||||
|
||||
it('should succeed with correct secret for permission_check_fn', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('permission_check_fn', { secret: 'open-sesame' })
|
||||
})
|
||||
|
||||
expect(response).toEqual({ message: 'access granted' })
|
||||
})
|
||||
|
||||
it('should return BAD_REQUEST for invalid JSON body', async () => {
|
||||
const response = await fetch(`${BACKEND_URL}/api/mizan/call/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: '{not valid json}',
|
||||
})
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe(true)
|
||||
expect(data.code).toBe('BAD_REQUEST')
|
||||
})
|
||||
|
||||
it('should return BAD_REQUEST for missing fn field', async () => {
|
||||
const response = await fetch(`${BACKEND_URL}/api/mizan/call/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ args: {} }),
|
||||
})
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe(true)
|
||||
expect(data.code).toBe('BAD_REQUEST')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 8: mizanFormMixin integration
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('mizanFormMixin integration', () => {
|
||||
it('should return schema with title, subtitle, and submit_label from mizanFormMeta', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('contact.schema', { data: {} })
|
||||
})
|
||||
|
||||
expect(response).toHaveProperty('fields')
|
||||
const fields = response.fields
|
||||
|
||||
// Contact form should have name, email, and message fields
|
||||
expect(fields).toHaveProperty('name')
|
||||
expect(fields).toHaveProperty('email')
|
||||
expect(fields).toHaveProperty('message')
|
||||
|
||||
// Meta should include title, subtitle, and submit_label
|
||||
expect(response).toHaveProperty('meta')
|
||||
expect(response.meta.title).toBe('Contact Us')
|
||||
expect(response.meta).toHaveProperty('subtitle')
|
||||
expect(response.meta.submit_label).toBe('Send Message')
|
||||
})
|
||||
|
||||
it('should return form meta with live_validation and live_form_errors', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('contact.schema', { data: {} })
|
||||
})
|
||||
|
||||
expect(response).toHaveProperty('meta')
|
||||
expect(response.meta.live_validation).toBe(true)
|
||||
expect(response.meta.live_form_errors).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate contact form and return field errors for missing fields', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('contact.validate', { data: {} })
|
||||
})
|
||||
|
||||
expect(response.valid).toBe(false)
|
||||
expect(response.errors).toBeInstanceOf(Array)
|
||||
expect(response.errors.length).toBeGreaterThan(0)
|
||||
|
||||
// Should have errors for name, email, and message
|
||||
const errorFieldNames = response.errors.map((e: any) => e.field)
|
||||
expect(errorFieldNames).toContain('name')
|
||||
expect(errorFieldNames).toContain('email')
|
||||
expect(errorFieldNames).toContain('message')
|
||||
})
|
||||
|
||||
it('should submit contact form successfully and get on_submit_success data', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('contact.submit', {
|
||||
name: 'Test',
|
||||
email: 'test@test.com',
|
||||
message: 'Hello',
|
||||
})
|
||||
})
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(response.data).toHaveProperty('received', true)
|
||||
expect(response.data).toHaveProperty('from', 'test@test.com')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 9: Formset integration
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('Formset integration', () => {
|
||||
it('should return formset schema for item form', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('item.formset.schema', { forms: [] })
|
||||
})
|
||||
|
||||
expect(response).toHaveProperty('min_num')
|
||||
expect(response).toHaveProperty('max_num')
|
||||
})
|
||||
|
||||
it('should validate formset with invalid data', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('item.formset.validate', {
|
||||
forms: [{ label: '', quantity: 0 }],
|
||||
})
|
||||
})
|
||||
|
||||
// Should have validation errors for the invalid form data
|
||||
expect(response).toHaveProperty('errors')
|
||||
})
|
||||
|
||||
it('should submit formset with valid data', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('item.formset.submit', {
|
||||
forms: [{ label: 'Widget', quantity: 5 }],
|
||||
})
|
||||
})
|
||||
|
||||
expect(response).toHaveProperty('success', true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 10: Channel authorization
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('Channel authorization', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeEach(async () => {
|
||||
connection = new ChannelConnection({ url: WS_URL, reconnect: false })
|
||||
connection.connect()
|
||||
await waitForConnected(connection)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
it('should reject subscription to private channel when anonymous', async () => {
|
||||
const msgPromise = new Promise<any>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Auth rejection timeout')), 5000)
|
||||
connection.onMessage((msg) => {
|
||||
if ('error' in msg) {
|
||||
clearTimeout(timeout)
|
||||
resolve(msg)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
connection.send({ action: 'subscribe', channel: 'private' })
|
||||
|
||||
const msg = await msgPromise
|
||||
expect(msg.error).toContain('Not authorized')
|
||||
})
|
||||
|
||||
it('should successfully unsubscribe from a channel', async () => {
|
||||
// First subscribe to chat
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Subscribe timeout')), 5000)
|
||||
const unsub = connection.onMessage((msg) => {
|
||||
if ('subscribed' in msg && msg.channel === 'chat') {
|
||||
clearTimeout(timeout)
|
||||
unsub()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
connection.send({
|
||||
action: 'subscribe',
|
||||
channel: 'chat',
|
||||
params: { room: 'unsub-test' },
|
||||
})
|
||||
})
|
||||
|
||||
// Now unsubscribe
|
||||
const unsubPromise = new Promise<any>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Unsubscribe timeout')), 5000)
|
||||
const unsub = connection.onMessage((msg) => {
|
||||
if ('unsubscribed' in msg && msg.channel === 'chat') {
|
||||
clearTimeout(timeout)
|
||||
unsub()
|
||||
resolve(msg)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
connection.send({
|
||||
action: 'unsubscribe',
|
||||
channel: 'chat',
|
||||
params: { room: 'unsub-test' },
|
||||
})
|
||||
|
||||
const unsubMsg = await unsubPromise
|
||||
expect(unsubMsg).toHaveProperty('unsubscribed', true)
|
||||
expect(unsubMsg).toHaveProperty('channel', 'chat')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 11: WebSocket RPC happy path
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('WebSocket RPC happy path', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeEach(async () => {
|
||||
connection = new ChannelConnection({ url: WS_URL, reconnect: false })
|
||||
connection.connect()
|
||||
await waitForConnected(connection)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
it('should call echo via RPC and get correct response', async () => {
|
||||
const response = await connection.rpc<{ text: string }, { message: string }>(
|
||||
'echo',
|
||||
{ text: 'ws rpc echo' }
|
||||
)
|
||||
|
||||
expect(response).toHaveProperty('message')
|
||||
expect(response.message).toContain('ws rpc echo')
|
||||
})
|
||||
|
||||
it('should call add via RPC and get correct sum', async () => {
|
||||
const response = await connection.rpc<{ a: number; b: number }, { result: number }>(
|
||||
'add',
|
||||
{ a: 100, b: 200 }
|
||||
)
|
||||
|
||||
expect(response).toEqual({ result: 300 })
|
||||
})
|
||||
|
||||
it('should reject multiply via RPC if not websocket-enabled', async () => {
|
||||
// multiply uses @register_as which may not set websocket=True
|
||||
// If it's HTTP-only, RPC should fail; if it supports WS, it should succeed
|
||||
let response: any = null
|
||||
let rpcError: RPCError | null = null
|
||||
try {
|
||||
response = await connection.rpc<{ x: number; y: number }, { product: number }>(
|
||||
'multiply',
|
||||
{ x: 7, y: 6 }
|
||||
)
|
||||
} catch (e) {
|
||||
rpcError = e as RPCError
|
||||
}
|
||||
|
||||
// Either it succeeds with the correct product, or it fails because it's HTTP-only
|
||||
if (rpcError) {
|
||||
expect(rpcError).toBeInstanceOf(RPCError)
|
||||
} else {
|
||||
expect(response).toEqual({ product: 42 })
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject ws_whoami via RPC when anonymous', async () => {
|
||||
let rpcError: RPCError | null = null
|
||||
try {
|
||||
await connection.rpc('ws_whoami', {})
|
||||
} catch (e) {
|
||||
rpcError = e as RPCError
|
||||
}
|
||||
|
||||
expect(rpcError).toBeInstanceOf(RPCError)
|
||||
expect(rpcError!.code).toBe('UNAUTHORIZED')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 12: Successful form submit flow
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('Form submit success flow', () => {
|
||||
it('should sign up a new user via signup form', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
// Use a unique email per run to avoid duplicate-user errors
|
||||
const uniqueEmail = `newuser+${Date.now()}@example.com`
|
||||
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('signup.submit', {
|
||||
email: uniqueEmail,
|
||||
password1: 'testpass123',
|
||||
})
|
||||
})
|
||||
|
||||
expect(response).toHaveProperty('success', true)
|
||||
expect(response).toHaveProperty('data')
|
||||
expect(response.data).toHaveProperty('user_id')
|
||||
expect(typeof response.data.user_id).toBe('number')
|
||||
})
|
||||
})
|
||||
165
frontends/mizan-react/src/channels/__tests__/connection.test.ts
Normal file
165
frontends/mizan-react/src/channels/__tests__/connection.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Tests for ChannelConnection
|
||||
*
|
||||
* These tests verify the ChannelConnection class API.
|
||||
* Unit tests for class structure don't require a real backend.
|
||||
* Integration tests for actual WebSocket connections require the backend.
|
||||
*
|
||||
* Backend must be running for integration tests: docker-compose up
|
||||
*/
|
||||
|
||||
import { ChannelConnection, RPCError } from '../connection'
|
||||
import { describeIntegration, WS_URL } from '../../testing'
|
||||
|
||||
describe('ChannelConnection (unit tests)', () => {
|
||||
describe('construction', () => {
|
||||
it('should start in disconnected state', () => {
|
||||
const connection = new ChannelConnection({ url: 'ws://localhost/ws/' })
|
||||
expect(connection.status).toBe('disconnected')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('status change handlers', () => {
|
||||
it('should allow subscribing to status changes', () => {
|
||||
const connection = new ChannelConnection({ url: 'ws://localhost/ws/' })
|
||||
const handler = jest.fn()
|
||||
|
||||
const unsubscribe = connection.onStatusChange(handler)
|
||||
|
||||
expect(typeof unsubscribe).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('message handlers', () => {
|
||||
it('should allow subscribing to messages', () => {
|
||||
const connection = new ChannelConnection({ url: 'ws://localhost/ws/' })
|
||||
const handler = jest.fn()
|
||||
|
||||
const unsubscribe = connection.onMessage(handler)
|
||||
|
||||
expect(typeof unsubscribe).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('send queueing', () => {
|
||||
it('should queue messages when not connected', () => {
|
||||
const connection = new ChannelConnection({
|
||||
url: 'ws://localhost/ws/',
|
||||
reconnect: false,
|
||||
})
|
||||
|
||||
// This shouldn't throw
|
||||
connection.send({
|
||||
action: 'subscribe',
|
||||
channel: 'test',
|
||||
params: {},
|
||||
})
|
||||
|
||||
// Status should still be disconnected (or connecting if it auto-connected)
|
||||
expect(['disconnected', 'connecting']).toContain(connection.status)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rpc', () => {
|
||||
it('should queue rpc messages when not connected', () => {
|
||||
const connection = new ChannelConnection({
|
||||
url: 'ws://localhost/ws/',
|
||||
reconnect: false,
|
||||
})
|
||||
|
||||
const promise = connection.rpc('test_fn', { arg: 'value' })
|
||||
|
||||
expect(promise).toBeInstanceOf(Promise)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describeIntegration('ChannelConnection (integration)', () => {
|
||||
describe('real WebSocket connection', () => {
|
||||
it('should connect to real backend WebSocket', async () => {
|
||||
const connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
|
||||
const statusChanges: string[] = []
|
||||
connection.onStatusChange((status) => {
|
||||
statusChanges.push(status)
|
||||
})
|
||||
|
||||
// Connect
|
||||
connection.connect()
|
||||
|
||||
// Wait for connection
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Connection timeout'))
|
||||
}, 5000)
|
||||
|
||||
const unsubscribe = connection.onStatusChange((status) => {
|
||||
if (status === 'connected') {
|
||||
clearTimeout(timeout)
|
||||
unsubscribe()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(connection.status).toBe('connected')
|
||||
|
||||
// Cleanup
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
it('should disconnect cleanly', async () => {
|
||||
const connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
|
||||
// Connect first
|
||||
connection.connect()
|
||||
await new Promise<void>((resolve) => {
|
||||
const unsubscribe = connection.onStatusChange((status) => {
|
||||
if (status === 'connected') {
|
||||
unsubscribe()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Now disconnect
|
||||
connection.disconnect()
|
||||
|
||||
// Should be disconnected
|
||||
expect(connection.status).toBe('disconnected')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('RPCError', () => {
|
||||
it('should be an Error subclass', () => {
|
||||
const error = new RPCError('TEST_CODE', 'Test message')
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error).toBeInstanceOf(RPCError)
|
||||
})
|
||||
|
||||
it('should have correct properties', () => {
|
||||
const error = new RPCError('VALIDATION_ERROR', 'Field is required', { field: 'email' })
|
||||
|
||||
expect(error.code).toBe('VALIDATION_ERROR')
|
||||
expect(error.message).toBe('Field is required')
|
||||
expect(error.details).toEqual({ field: 'email' })
|
||||
expect(error.name).toBe('RPCError')
|
||||
})
|
||||
|
||||
it('should work without details', () => {
|
||||
const error = new RPCError('NOT_FOUND', 'Function not found')
|
||||
|
||||
expect(error.code).toBe('NOT_FOUND')
|
||||
expect(error.message).toBe('Function not found')
|
||||
expect(error.details).toBeUndefined()
|
||||
})
|
||||
})
|
||||
207
frontends/mizan-react/src/channels/__tests__/context.test.tsx
Normal file
207
frontends/mizan-react/src/channels/__tests__/context.test.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Tests for ChannelProvider context
|
||||
*
|
||||
* Unit tests run without backend.
|
||||
* Integration tests require: docker-compose up
|
||||
*
|
||||
* Run integration tests with: RUN_INTEGRATION_TESTS=true npm run test
|
||||
*/
|
||||
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { ReactNode } from 'react'
|
||||
import { ChannelProvider, useChannelContext, useChannelStatus } from '../context'
|
||||
import { ChannelConnection } from '../connection'
|
||||
import { describeIntegration, WS_URL } from '../../testing'
|
||||
|
||||
// ============================================================================
|
||||
// Unit Tests (no backend required)
|
||||
// ============================================================================
|
||||
|
||||
describe('ChannelProvider (unit)', () => {
|
||||
describe('useChannelContext', () => {
|
||||
it('should throw when used outside ChannelProvider', () => {
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useChannelContext())
|
||||
}).toThrow('useChannelContext must be used within a ChannelProvider')
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should return connection and status when inside provider', () => {
|
||||
const connection = new ChannelConnection({
|
||||
url: 'ws://localhost/ws/',
|
||||
reconnect: false,
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ChannelProvider connection={connection} autoConnect={false}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useChannelContext(), { wrapper })
|
||||
|
||||
expect(result.current.connection).toBe(connection)
|
||||
expect(result.current.status).toBe('disconnected')
|
||||
|
||||
connection.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useChannelStatus', () => {
|
||||
it('should return disconnected when autoConnect is false', () => {
|
||||
const connection = new ChannelConnection({
|
||||
url: 'ws://localhost/ws/',
|
||||
reconnect: false,
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ChannelProvider connection={connection} autoConnect={false}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useChannelStatus(), { wrapper })
|
||||
expect(result.current).toBe('disconnected')
|
||||
|
||||
connection.disconnect()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Integration Tests (require running backend)
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('ChannelProvider (integration)', () => {
|
||||
describe('with real WebSocket connection', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
const createWrapper = (autoConnect = true) => {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ChannelProvider
|
||||
connection={connection}
|
||||
autoConnect={autoConnect}
|
||||
>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it('should auto-connect when autoConnect is true', async () => {
|
||||
const { result } = renderHook(() => useChannelContext(), {
|
||||
wrapper: createWrapper(true),
|
||||
})
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('should not auto-connect when autoConnect is false', () => {
|
||||
const { result } = renderHook(() => useChannelContext(), {
|
||||
wrapper: createWrapper(false),
|
||||
})
|
||||
|
||||
expect(result.current.status).toBe('disconnected')
|
||||
})
|
||||
|
||||
it('should update status when connection status changes', async () => {
|
||||
const { result } = renderHook(() => useChannelStatus(), {
|
||||
wrapper: createWrapper(true),
|
||||
})
|
||||
|
||||
// Should start connecting then become connected
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('should disconnect on unmount', async () => {
|
||||
const { result, unmount } = renderHook(() => useChannelContext(), {
|
||||
wrapper: createWrapper(true),
|
||||
})
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Unmount
|
||||
unmount()
|
||||
|
||||
// Connection should be disconnected
|
||||
expect(connection.status).toBe('disconnected')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describeIntegration('useChannelStatus (integration)', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
const createWrapper = (autoConnect: boolean) => {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ChannelProvider connection={connection} autoConnect={autoConnect}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it('should return current connection status', () => {
|
||||
const { result } = renderHook(() => useChannelStatus(), {
|
||||
wrapper: createWrapper(false),
|
||||
})
|
||||
|
||||
expect(result.current).toBe('disconnected')
|
||||
})
|
||||
|
||||
it('should track status through connection lifecycle', async () => {
|
||||
const { result } = renderHook(() => useChannelStatus(), {
|
||||
wrapper: createWrapper(true),
|
||||
})
|
||||
|
||||
// Wait for connected
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Disconnect manually
|
||||
act(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
// Should become disconnected
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('disconnected')
|
||||
})
|
||||
})
|
||||
})
|
||||
158
frontends/mizan-react/src/channels/__tests__/hooks.test.tsx
Normal file
158
frontends/mizan-react/src/channels/__tests__/hooks.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Integration tests for channel hooks
|
||||
*
|
||||
* These tests call the REAL backend - no mocks.
|
||||
* Backend must be running: docker-compose up
|
||||
*
|
||||
* Run with: RUN_INTEGRATION_TESTS=true npm run test
|
||||
*/
|
||||
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { ReactNode } from 'react'
|
||||
import { ChannelProvider } from '../context'
|
||||
import { useChannel, useChannelLatest, useRPC } from '../hooks'
|
||||
import { ChannelConnection } from '../connection'
|
||||
import { describeIntegration, WS_URL } from '../../testing'
|
||||
|
||||
describeIntegration('useChannel (integration)', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ChannelProvider connection={connection} autoConnect={true}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
describe('subscription', () => {
|
||||
it('should subscribe to channel when connection is ready', async () => {
|
||||
const { result } = renderHook(
|
||||
() => useChannel<{ room: string }, { text: string }, { text: string }>('chat', { room: 'test' }),
|
||||
{ wrapper: createWrapper() }
|
||||
)
|
||||
|
||||
// Wait for connection to establish
|
||||
await waitFor(() => {
|
||||
// Status should progress from connecting
|
||||
expect(['connecting', 'connected', 'subscribed']).toContain(result.current.status)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Should have expected API
|
||||
expect(typeof result.current.send).toBe('function')
|
||||
expect(typeof result.current.clearMessages).toBe('function')
|
||||
expect(typeof result.current.unsubscribe).toBe('function')
|
||||
expect(Array.isArray(result.current.messages)).toBe(true)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
describeIntegration('useChannelLatest (integration)', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ChannelProvider connection={connection} autoConnect={true}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
describeIntegration('useRPC (integration)', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ChannelProvider connection={connection} autoConnect={true}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it('should track connection status', async () => {
|
||||
const { result } = renderHook(() => useRPC(), { wrapper: createWrapper() })
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('should call backend echo function via RPC', async () => {
|
||||
const { result } = renderHook(() => useRPC(), { wrapper: createWrapper() })
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Call echo function
|
||||
const response = await result.current.call<{ text: string }, { message: string }>(
|
||||
'echo',
|
||||
{ text: 'rpc test' }
|
||||
)
|
||||
|
||||
expect(response).toHaveProperty('message')
|
||||
expect(response.message).toContain('rpc test')
|
||||
})
|
||||
|
||||
it('should call backend add function via RPC', async () => {
|
||||
const { result } = renderHook(() => useRPC(), { wrapper: createWrapper() })
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Call add function
|
||||
const response = await result.current.call<{ a: number; b: number }, { result: number }>(
|
||||
'add',
|
||||
{ a: 7, b: 8 }
|
||||
)
|
||||
|
||||
expect(response).toHaveProperty('result', 15)
|
||||
})
|
||||
})
|
||||
|
||||
299
frontends/mizan-react/src/channels/connection.ts
Normal file
299
frontends/mizan-react/src/channels/connection.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* WebSocket connection manager for mizan/channels
|
||||
*
|
||||
* Supports both pub/sub channels AND RPC calls over the same connection.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
OutgoingMessage,
|
||||
IncomingPayload,
|
||||
SubscribeOptions,
|
||||
} from './types'
|
||||
|
||||
type MessageHandler = (payload: IncomingPayload) => void
|
||||
type StatusHandler = (status: ConnectionStatus) => void
|
||||
|
||||
/** RPC request message */
|
||||
export interface RPCRequest<T = unknown> {
|
||||
action: 'rpc'
|
||||
id: string
|
||||
fn: string
|
||||
args: T
|
||||
}
|
||||
|
||||
/** RPC response - success */
|
||||
export interface RPCSuccessResponse<T = unknown> {
|
||||
id: string
|
||||
ok: true
|
||||
data: T
|
||||
}
|
||||
|
||||
/** RPC response - error */
|
||||
export interface RPCErrorResponse {
|
||||
id: string
|
||||
ok: false
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export type RPCResponse<T = unknown> = RPCSuccessResponse<T> | RPCErrorResponse
|
||||
|
||||
/** RPC error thrown on failure */
|
||||
export class RPCError extends Error {
|
||||
code: string
|
||||
details?: Record<string, unknown>
|
||||
|
||||
constructor(code: string, message: string, details?: Record<string, unknown>) {
|
||||
super(message)
|
||||
this.name = 'RPCError'
|
||||
this.code = code
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChannelConnectionOptions {
|
||||
/** WebSocket URL (default: /ws/) */
|
||||
url?: string
|
||||
|
||||
/** Reconnect on disconnect (default: true) */
|
||||
reconnect?: boolean
|
||||
|
||||
/** Reconnection delay in ms (default: 1000) */
|
||||
reconnectDelay?: number
|
||||
|
||||
/** Maximum reconnection attempts (default: 10) */
|
||||
maxReconnectAttempts?: number
|
||||
}
|
||||
|
||||
export class ChannelConnection {
|
||||
private ws: WebSocket | null = null
|
||||
private url: string
|
||||
private reconnect: boolean
|
||||
private reconnectDelay: number
|
||||
private maxReconnectAttempts: number
|
||||
private reconnectAttempts = 0
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
private messageHandlers: Set<MessageHandler> = new Set()
|
||||
private statusHandlers: Set<StatusHandler> = new Set()
|
||||
|
||||
private _status: ConnectionStatus = 'disconnected'
|
||||
private pendingMessages: OutgoingMessage[] = []
|
||||
|
||||
// RPC state
|
||||
private rpcIdCounter = 0
|
||||
private pendingRPCs: Map<string, {
|
||||
resolve: (data: unknown) => void
|
||||
reject: (error: RPCError) => void
|
||||
}> = new Map()
|
||||
|
||||
constructor(options: ChannelConnectionOptions = {}) {
|
||||
// Build WebSocket URL
|
||||
const baseUrl = options.url || '/ws/'
|
||||
if (typeof window !== 'undefined') {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
this.url = baseUrl.startsWith('ws')
|
||||
? baseUrl
|
||||
: `${protocol}//${window.location.host}${baseUrl}`
|
||||
} else {
|
||||
this.url = baseUrl
|
||||
}
|
||||
|
||||
this.reconnect = options.reconnect ?? true
|
||||
this.reconnectDelay = options.reconnectDelay ?? 1000
|
||||
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10
|
||||
}
|
||||
|
||||
get status(): ConnectionStatus {
|
||||
return this._status
|
||||
}
|
||||
|
||||
private setStatus(status: ConnectionStatus) {
|
||||
this._status = status
|
||||
this.statusHandlers.forEach(handler => handler(status))
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setStatus('connecting')
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0
|
||||
this.setStatus('connected')
|
||||
|
||||
// Send any pending messages
|
||||
this.pendingMessages.forEach(msg => this.send(msg))
|
||||
this.pendingMessages = []
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
this.setStatus('disconnected')
|
||||
|
||||
// Attempt reconnection if enabled
|
||||
if (this.reconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// WebSocket errors don't provide useful details (browser security)
|
||||
// The onclose handler will fire next and trigger reconnection
|
||||
console.warn('[ChannelConnection] WebSocket error (will reconnect)')
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
|
||||
// Check if this is an RPC response (has 'id' and 'ok' fields)
|
||||
if ('id' in payload && 'ok' in payload) {
|
||||
this.handleRPCResponse(payload as RPCResponse)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, it's a channel message
|
||||
this.messageHandlers.forEach(handler => handler(payload as IncomingPayload))
|
||||
} catch (e) {
|
||||
console.error('[ChannelConnection] Failed to parse message:', e)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ChannelConnection] Failed to connect:', error)
|
||||
this.setStatus('disconnected')
|
||||
|
||||
if (this.reconnect) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.reconnect = false
|
||||
this.clearReconnectTimer()
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
|
||||
this.setStatus('disconnected')
|
||||
}
|
||||
|
||||
send(message: OutgoingMessage): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message))
|
||||
} else {
|
||||
// Queue message to send when connected
|
||||
this.pendingMessages.push(message)
|
||||
|
||||
// Ensure we're trying to connect
|
||||
if (this._status === 'disconnected') {
|
||||
this.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMessage(handler: MessageHandler): () => void {
|
||||
this.messageHandlers.add(handler)
|
||||
return () => this.messageHandlers.delete(handler)
|
||||
}
|
||||
|
||||
onStatusChange(handler: StatusHandler): () => void {
|
||||
this.statusHandlers.add(handler)
|
||||
return () => this.statusHandlers.delete(handler)
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.clearReconnectTimer()
|
||||
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts)
|
||||
this.reconnectAttempts++
|
||||
|
||||
console.log(`[ChannelConnection] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// RPC Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Call a server function via RPC.
|
||||
*
|
||||
* @param fn - Function name (as registered on backend)
|
||||
* @param args - Function arguments
|
||||
* @returns Promise resolving to function output
|
||||
* @throws RPCError on failure
|
||||
*/
|
||||
rpc<TInput, TOutput>(fn: string, args: TInput): Promise<TOutput> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = `rpc_${++this.rpcIdCounter}_${Date.now()}`
|
||||
|
||||
// Store pending RPC
|
||||
this.pendingRPCs.set(id, {
|
||||
resolve: resolve as (data: unknown) => void,
|
||||
reject,
|
||||
})
|
||||
|
||||
// Send RPC request
|
||||
const request: RPCRequest<TInput> = {
|
||||
action: 'rpc',
|
||||
id,
|
||||
fn,
|
||||
args,
|
||||
}
|
||||
|
||||
this.send(request as unknown as OutgoingMessage)
|
||||
})
|
||||
}
|
||||
|
||||
private handleRPCResponse(response: RPCResponse): void {
|
||||
const pending = this.pendingRPCs.get(response.id)
|
||||
if (!pending) {
|
||||
console.warn(`[ChannelConnection] Received RPC response for unknown id: ${response.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingRPCs.delete(response.id)
|
||||
|
||||
if (response.ok) {
|
||||
pending.resolve(response.data)
|
||||
} else {
|
||||
pending.reject(new RPCError(
|
||||
response.error.code,
|
||||
response.error.message,
|
||||
response.error.details,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton connection instance
|
||||
let defaultConnection: ChannelConnection | null = null
|
||||
|
||||
export function getDefaultConnection(options?: ChannelConnectionOptions): ChannelConnection {
|
||||
if (!defaultConnection) {
|
||||
defaultConnection = new ChannelConnection(options)
|
||||
}
|
||||
return defaultConnection
|
||||
}
|
||||
102
frontends/mizan-react/src/channels/context.tsx
Normal file
102
frontends/mizan-react/src/channels/context.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* React context for mizan/channels
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { ChannelConnection, type ChannelConnectionOptions } from './connection'
|
||||
import type { ConnectionStatus } from './types'
|
||||
|
||||
interface ChannelContextValue {
|
||||
connection: ChannelConnection
|
||||
status: ConnectionStatus
|
||||
}
|
||||
|
||||
const ChannelContext = createContext<ChannelContextValue | null>(null)
|
||||
|
||||
export interface ChannelProviderProps {
|
||||
children: ReactNode
|
||||
|
||||
/** WebSocket URL (default: /ws/) */
|
||||
url?: string
|
||||
|
||||
/** Reconnect on disconnect (default: true) */
|
||||
reconnect?: boolean
|
||||
|
||||
/** Reconnection delay in ms (default: 1000) */
|
||||
reconnectDelay?: number
|
||||
|
||||
/** Maximum reconnection attempts (default: 10) */
|
||||
maxReconnectAttempts?: number
|
||||
|
||||
/** Connect automatically on mount (default: true) */
|
||||
autoConnect?: boolean
|
||||
|
||||
/** Custom connection instance (for testing) */
|
||||
connection?: ChannelConnection
|
||||
}
|
||||
|
||||
export function ChannelProvider({
|
||||
children,
|
||||
url,
|
||||
reconnect,
|
||||
reconnectDelay,
|
||||
maxReconnectAttempts,
|
||||
autoConnect = true,
|
||||
connection: providedConnection,
|
||||
}: ChannelProviderProps) {
|
||||
const connectionRef = useRef<ChannelConnection | null>(null)
|
||||
|
||||
// Use provided connection or create one
|
||||
if (!connectionRef.current) {
|
||||
connectionRef.current = providedConnection ?? new ChannelConnection({
|
||||
url,
|
||||
reconnect,
|
||||
reconnectDelay,
|
||||
maxReconnectAttempts,
|
||||
})
|
||||
}
|
||||
|
||||
const connection = connectionRef.current
|
||||
|
||||
// Track status for context value
|
||||
const [status, setStatus] = useState<ConnectionStatus>(connection.status)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = connection.onStatusChange(setStatus)
|
||||
|
||||
if (autoConnect) {
|
||||
connection.connect()
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
connection.disconnect()
|
||||
}
|
||||
}, [connection, autoConnect])
|
||||
|
||||
const value = useMemo(() => ({
|
||||
connection,
|
||||
status,
|
||||
}), [connection, status])
|
||||
|
||||
return (
|
||||
<ChannelContext value={value}>
|
||||
{children}
|
||||
</ChannelContext>
|
||||
)
|
||||
}
|
||||
|
||||
export function useChannelContext(): ChannelContextValue {
|
||||
const context = useContext(ChannelContext)
|
||||
if (!context) {
|
||||
throw new Error('useChannelContext must be used within a ChannelProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function useChannelStatus(): ConnectionStatus {
|
||||
const { status } = useChannelContext()
|
||||
return status
|
||||
}
|
||||
256
frontends/mizan-react/src/channels/hooks.ts
Normal file
256
frontends/mizan-react/src/channels/hooks.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* React hooks for mizan/channels
|
||||
*
|
||||
* Includes pub/sub channel hooks AND RPC hooks.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useChannelContext } from './context'
|
||||
import { RPCError } from './connection'
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
ChannelSubscription,
|
||||
IncomingPayload,
|
||||
} from './types'
|
||||
|
||||
export interface UseChannelOptions<TServerMessage> {
|
||||
/** Called when subscribed successfully */
|
||||
onSubscribed?: () => void
|
||||
|
||||
/** Called when a message is received */
|
||||
onMessage?: (message: TServerMessage) => void
|
||||
|
||||
/** Called on error */
|
||||
onError?: (error: string) => void
|
||||
|
||||
/** Called when unsubscribed */
|
||||
onUnsubscribed?: () => void
|
||||
|
||||
/** Maximum messages to keep in history (default: 100) */
|
||||
maxMessages?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a channel and receive typed messages.
|
||||
*
|
||||
* @param channelName - The registered channel name
|
||||
* @param params - Channel parameters (if required)
|
||||
* @param options - Subscription options
|
||||
*/
|
||||
export function useChannel<
|
||||
TParams = undefined,
|
||||
TServerMessage = unknown,
|
||||
TReactMessage = unknown,
|
||||
>(
|
||||
channelName: string,
|
||||
params?: TParams,
|
||||
options: UseChannelOptions<TServerMessage> = {},
|
||||
): ChannelSubscription<TParams, TServerMessage, TReactMessage> {
|
||||
const { connection, status: connectionStatus } = useChannelContext()
|
||||
|
||||
const [messages, setMessages] = useState<TServerMessage[]>([])
|
||||
const [subscribed, setSubscribed] = useState(false)
|
||||
|
||||
const optionsRef = useRef(options)
|
||||
optionsRef.current = options
|
||||
|
||||
const maxMessages = options.maxMessages ?? 100
|
||||
|
||||
// Stable params reference for effect dependencies
|
||||
const paramsJson = JSON.stringify(params ?? {})
|
||||
const paramsRef = useRef(params)
|
||||
paramsRef.current = params
|
||||
|
||||
// Subscribe on mount / when params change
|
||||
useEffect(() => {
|
||||
if (connectionStatus !== 'connected') {
|
||||
return
|
||||
}
|
||||
|
||||
const currentParams = paramsRef.current ?? {}
|
||||
|
||||
// Subscribe
|
||||
connection.send({
|
||||
action: 'subscribe',
|
||||
channel: channelName,
|
||||
params: currentParams as Record<string, unknown>,
|
||||
})
|
||||
|
||||
// Handle incoming messages
|
||||
const unsubscribeMessages = connection.onMessage((payload: IncomingPayload) => {
|
||||
// Check for subscription confirmation
|
||||
if ('subscribed' in payload && payload.channel === channelName) {
|
||||
setSubscribed(true)
|
||||
optionsRef.current.onSubscribed?.()
|
||||
return
|
||||
}
|
||||
|
||||
// Check for unsubscription confirmation
|
||||
if ('unsubscribed' in payload && payload.channel === channelName) {
|
||||
setSubscribed(false)
|
||||
optionsRef.current.onUnsubscribed?.()
|
||||
return
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
if ('error' in payload) {
|
||||
if (!payload.channel || payload.channel === channelName) {
|
||||
optionsRef.current.onError?.(payload.error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle data messages
|
||||
if ('type' in payload && 'data' in payload) {
|
||||
const message = payload.data as TServerMessage
|
||||
setMessages(prev => {
|
||||
const next = [...prev, message]
|
||||
// Trim to max messages
|
||||
if (next.length > maxMessages) {
|
||||
return next.slice(-maxMessages)
|
||||
}
|
||||
return next
|
||||
})
|
||||
optionsRef.current.onMessage?.(message)
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup: unsubscribe
|
||||
return () => {
|
||||
unsubscribeMessages()
|
||||
|
||||
connection.send({
|
||||
action: 'unsubscribe',
|
||||
channel: channelName,
|
||||
params: currentParams as Record<string, unknown>,
|
||||
})
|
||||
}
|
||||
}, [connection, connectionStatus, channelName, paramsJson, maxMessages])
|
||||
|
||||
// Send function
|
||||
const send = useCallback((message: TReactMessage) => {
|
||||
if (!subscribed) {
|
||||
console.warn(`[useChannel] Cannot send: not subscribed to ${channelName}`)
|
||||
return
|
||||
}
|
||||
|
||||
connection.send({
|
||||
action: 'message',
|
||||
channel: channelName,
|
||||
params: (paramsRef.current ?? {}) as Record<string, unknown>,
|
||||
data: message,
|
||||
})
|
||||
}, [connection, channelName, subscribed])
|
||||
|
||||
// Unsubscribe function
|
||||
const unsubscribe = useCallback(() => {
|
||||
connection.send({
|
||||
action: 'unsubscribe',
|
||||
channel: channelName,
|
||||
params: (paramsRef.current ?? {}) as Record<string, unknown>,
|
||||
})
|
||||
}, [connection, channelName])
|
||||
|
||||
// Clear messages
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([])
|
||||
}, [])
|
||||
|
||||
// Derive status
|
||||
const status: ConnectionStatus = !subscribed
|
||||
? connectionStatus === 'connected' ? 'connecting' : connectionStatus
|
||||
: 'connected'
|
||||
|
||||
return {
|
||||
status,
|
||||
messages,
|
||||
send: send as ChannelSubscription<TParams, TServerMessage, TReactMessage>['send'],
|
||||
unsubscribe,
|
||||
clearMessages,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only the latest message from a channel (useful for presence, typing indicators)
|
||||
*/
|
||||
export function useChannelLatest<
|
||||
TParams = undefined,
|
||||
TServerMessage = unknown,
|
||||
TReactMessage = unknown,
|
||||
>(
|
||||
channelName: string,
|
||||
params?: TParams,
|
||||
options: UseChannelOptions<TServerMessage> = {},
|
||||
): Omit<ChannelSubscription<TParams, TServerMessage, TReactMessage>, 'messages'> & { latest: TServerMessage | null } {
|
||||
const [latest, setLatest] = useState<TServerMessage | null>(null)
|
||||
|
||||
const channel = useChannel<TParams, TServerMessage, TReactMessage>(
|
||||
channelName,
|
||||
params,
|
||||
{
|
||||
...options,
|
||||
onMessage: (msg) => {
|
||||
setLatest(msg)
|
||||
options.onMessage?.(msg)
|
||||
},
|
||||
maxMessages: 1,
|
||||
},
|
||||
)
|
||||
|
||||
// Explicitly exclude messages to match the documented API
|
||||
const { messages: _, ...rest } = channel
|
||||
|
||||
return {
|
||||
...rest,
|
||||
latest,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// RPC Hooks
|
||||
// =============================================================================
|
||||
|
||||
export interface RPCClient {
|
||||
/**
|
||||
* Call a server function.
|
||||
*
|
||||
* @param fn - Function name
|
||||
* @param args - Function arguments
|
||||
* @returns Promise resolving to function output
|
||||
* @throws RPCError on failure
|
||||
*/
|
||||
call<TInput, TOutput>(fn: string, args: TInput): Promise<TOutput>
|
||||
|
||||
/** Connection status */
|
||||
status: ConnectionStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an RPC client for calling server functions.
|
||||
*
|
||||
* Usage:
|
||||
* const rpc = useRPC()
|
||||
* const result = await rpc.call('update_profile', { name: 'New Name' })
|
||||
*
|
||||
* The generated code wraps this with typed functions:
|
||||
* const { updateProfile } = useDjango()
|
||||
* const result = await updateProfile({ name: 'New Name' })
|
||||
*/
|
||||
export function useRPC(): RPCClient {
|
||||
const { connection, status } = useChannelContext()
|
||||
|
||||
const call = useCallback(<TInput, TOutput>(fn: string, args: TInput): Promise<TOutput> => {
|
||||
return connection.rpc<TInput, TOutput>(fn, args)
|
||||
}, [connection])
|
||||
|
||||
return {
|
||||
call,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export RPCError for convenience
|
||||
export { RPCError }
|
||||
76
frontends/mizan-react/src/channels/index.ts
Normal file
76
frontends/mizan-react/src/channels/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* mizan/channels
|
||||
*
|
||||
* Real-time WebSocket communication with Django Channels.
|
||||
* Type-safe bidirectional messaging.
|
||||
*
|
||||
* ## Setup
|
||||
*
|
||||
* ```tsx
|
||||
* // layout.tsx
|
||||
* import { ChannelProvider } from 'mizan/channels'
|
||||
*
|
||||
* export default function Layout({ children }) {
|
||||
* return (
|
||||
* <ChannelProvider>
|
||||
* {children}
|
||||
* </ChannelProvider>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* ```tsx
|
||||
* // Using generated hooks (recommended)
|
||||
* import { useChatChannel } from '@/api/generated.channels'
|
||||
*
|
||||
* function Chat({ room }) {
|
||||
* const chat = useChatChannel({ room })
|
||||
*
|
||||
* chat.status // 'connecting' | 'connected' | 'disconnected'
|
||||
* chat.messages // DjangoMessage[]
|
||||
* chat.send({ text: 'Hello' }) // Send ReactMessage
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ```tsx
|
||||
* // Using raw hook (for custom channels)
|
||||
* import { useChannel } from 'mizan/channels'
|
||||
*
|
||||
* function CustomChannel() {
|
||||
* const channel = useChannel<
|
||||
* { room: string }, // Params
|
||||
* { user: string; text: string }, // DjangoMessage
|
||||
* { text: string } // ReactMessage
|
||||
* >('chat', { room: 'general' })
|
||||
*
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Context
|
||||
export { ChannelProvider, useChannelContext, useChannelStatus } from './context'
|
||||
export type { ChannelProviderProps } from './context'
|
||||
|
||||
// Hooks
|
||||
export { useChannel, useChannelLatest, useRPC, RPCError } from './hooks'
|
||||
export type { UseChannelOptions, RPCClient } from './hooks'
|
||||
|
||||
// Connection (for advanced use)
|
||||
export { ChannelConnection, getDefaultConnection } from './connection'
|
||||
export type {
|
||||
ChannelConnectionOptions,
|
||||
RPCRequest,
|
||||
RPCResponse,
|
||||
RPCSuccessResponse,
|
||||
RPCErrorResponse,
|
||||
} from './connection'
|
||||
|
||||
// Types
|
||||
export type {
|
||||
ConnectionStatus,
|
||||
ChannelSubscription,
|
||||
SubscribeOptions,
|
||||
} from './types'
|
||||
84
frontends/mizan-react/src/channels/types.ts
Normal file
84
frontends/mizan-react/src/channels/types.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Types for mizan/channels
|
||||
*/
|
||||
|
||||
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected'
|
||||
|
||||
export interface ChannelSubscription<TParams = unknown, TServerMessage = unknown, TReactMessage = unknown> {
|
||||
/** Current connection status */
|
||||
status: ConnectionStatus
|
||||
|
||||
/** Received messages */
|
||||
messages: TServerMessage[]
|
||||
|
||||
/** Send a message (if channel accepts ReactMessage) */
|
||||
send: TReactMessage extends never ? never : (message: TReactMessage) => void
|
||||
|
||||
/** Unsubscribe from the channel */
|
||||
unsubscribe: () => void
|
||||
|
||||
/** Clear accumulated messages */
|
||||
clearMessages: () => void
|
||||
}
|
||||
|
||||
export interface SubscribeOptions {
|
||||
/** Called when subscribed successfully */
|
||||
onSubscribed?: () => void
|
||||
|
||||
/** Called when a message is received */
|
||||
onMessage?: (message: unknown) => void
|
||||
|
||||
/** Called on error */
|
||||
onError?: (error: string) => void
|
||||
|
||||
/** Called when unsubscribed */
|
||||
onUnsubscribed?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol messages sent over the WebSocket
|
||||
*/
|
||||
export interface SubscribeAction {
|
||||
action: 'subscribe'
|
||||
channel: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface UnsubscribeAction {
|
||||
action: 'unsubscribe'
|
||||
channel: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface MessageAction {
|
||||
action: 'message'
|
||||
channel: string
|
||||
params: Record<string, unknown>
|
||||
data: unknown
|
||||
}
|
||||
|
||||
export type OutgoingMessage = SubscribeAction | UnsubscribeAction | MessageAction
|
||||
|
||||
export interface IncomingSubscribed {
|
||||
subscribed: true
|
||||
channel: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface IncomingUnsubscribed {
|
||||
unsubscribed: true
|
||||
channel: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface IncomingMessage {
|
||||
type: string
|
||||
data: unknown
|
||||
}
|
||||
|
||||
export interface IncomingError {
|
||||
error: string
|
||||
channel?: string
|
||||
}
|
||||
|
||||
export type IncomingPayload = IncomingSubscribed | IncomingUnsubscribed | IncomingMessage | IncomingError
|
||||
524
frontends/mizan-react/src/client/index.ts
Normal file
524
frontends/mizan-react/src/client/index.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* mizan/client
|
||||
*
|
||||
* HTTP client factories for Django backends.
|
||||
* Framework-agnostic - works with vanilla JS, React, Vue, etc.
|
||||
*
|
||||
* ## Quick Start
|
||||
*
|
||||
* ### Client-Side (CSR)
|
||||
* ```ts
|
||||
* import { createMizanCSRClient, Auth } from 'mizan/client'
|
||||
*
|
||||
* // Session-based (cookies + CSRF)
|
||||
* const client = createMizanCSRClient(Auth.SESSION)
|
||||
*
|
||||
* // JWT-based (Bearer token)
|
||||
* const client = createMizanCSRClient(Auth.JWT, { getAccessToken })
|
||||
*
|
||||
* const user = await client.json('GET', '/api/accounts/me/')
|
||||
* ```
|
||||
*
|
||||
* ### Server-Side (SSR)
|
||||
* ```ts
|
||||
* import { createMizanSSRClient } from 'mizan/client'
|
||||
*
|
||||
* const client = createMizanSSRClient({
|
||||
* cookies: await cookies() // Next.js cookies()
|
||||
* })
|
||||
*
|
||||
* const user = await client.json('GET', '/api/accounts/me/')
|
||||
* ```
|
||||
*
|
||||
* ## React Hooks
|
||||
*
|
||||
* For React, import from `/react`:
|
||||
* ```tsx
|
||||
* import { useDjangoCSRClient, Auth } from 'mizan/client/react'
|
||||
*
|
||||
* const client = useDjangoCSRClient(Auth.SESSION)
|
||||
* ```
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Authentication strategy for client-side requests.
|
||||
*/
|
||||
export enum Auth {
|
||||
/** Session cookies with CSRF token */
|
||||
SESSION = 'session',
|
||||
/** JWT Bearer token */
|
||||
JWT = 'jwt',
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie getter interface (matches Next.js cookies() return type).
|
||||
*/
|
||||
export interface CookieGetter {
|
||||
get(name: string): { name: string; value: string } | undefined
|
||||
getAll(): { name: string; value: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie configuration for SSR requests.
|
||||
* Can be either a cookie getter (like Next.js cookies()) or pre-extracted values.
|
||||
*/
|
||||
export type SSRCookies = CookieGetter | {
|
||||
/** CSRF token value */
|
||||
csrf: string
|
||||
/** Full cookie header string */
|
||||
cookieHeader: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The core HTTP client interface for Django requests.
|
||||
*/
|
||||
export interface MizanHTTPClient {
|
||||
/**
|
||||
* Make an HTTP request, returning the raw Response.
|
||||
*/
|
||||
request(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<Response>
|
||||
|
||||
/**
|
||||
* Make an HTTP request, parsing the response as JSON.
|
||||
* @throws {HttpError} When response is not ok
|
||||
*/
|
||||
json<T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for CSR client.
|
||||
*/
|
||||
export interface CSRClientConfig {
|
||||
/** Base URL for the Django backend */
|
||||
baseUrl?: string | (() => string)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for JWT-authenticated CSR client.
|
||||
*/
|
||||
export interface JWTClientConfig extends CSRClientConfig {
|
||||
/** Async function that returns the current access token */
|
||||
getAccessToken: () => Promise<string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for SSR client.
|
||||
*/
|
||||
export interface SSRClientConfig {
|
||||
/** Cookies for authentication forwarding */
|
||||
cookies: SSRCookies
|
||||
/** Internal backend URL override (defaults to http://${INTERNAL_BACKEND_HOSTNAME}:8000) */
|
||||
baseUrl?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Errors
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Details about an HTTP error.
|
||||
*/
|
||||
export interface HttpErrorDetails {
|
||||
status: number
|
||||
statusText: string
|
||||
url: string
|
||||
bodyJson?: unknown
|
||||
bodySnippet?: string
|
||||
bodyIsHtml?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when an HTTP request fails.
|
||||
*/
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly details: HttpErrorDetails
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'HttpError'
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Internal Utilities
|
||||
// =============================================================================
|
||||
|
||||
import { getCSRFToken, getCsrfHeaderName, getCsrfCookieName } from '../utils'
|
||||
|
||||
interface RequestBuild {
|
||||
request: RequestInit
|
||||
hasBody: boolean
|
||||
}
|
||||
|
||||
function buildRequest(method: string, data?: unknown, headers?: Record<string, string>): RequestBuild {
|
||||
const isBodyMethod = !['GET', 'HEAD'].includes(method.toUpperCase())
|
||||
const hasBody = isBodyMethod && data !== undefined
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
...headers,
|
||||
}
|
||||
|
||||
let body: BodyInit | undefined
|
||||
if (hasBody) {
|
||||
if (data instanceof FormData) {
|
||||
body = data
|
||||
} else {
|
||||
body = JSON.stringify(data)
|
||||
requestHeaders['Content-Type'] = 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
request: {
|
||||
method: method.toUpperCase(),
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
credentials: 'include',
|
||||
},
|
||||
hasBody,
|
||||
}
|
||||
}
|
||||
|
||||
async function buildHttpError(resp: Response, url: URL | string): Promise<HttpError> {
|
||||
const urlStr = typeof url === 'string' ? url : url.toString()
|
||||
const details: HttpErrorDetails = {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
url: urlStr,
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = resp.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('application/json')) {
|
||||
details.bodyJson = await resp.clone().json()
|
||||
} else {
|
||||
const text = await resp.clone().text()
|
||||
details.bodyIsHtml = contentType.includes('text/html')
|
||||
details.bodySnippet = text.slice(0, 500)
|
||||
}
|
||||
} catch {
|
||||
// Ignore body parsing errors
|
||||
}
|
||||
|
||||
return new HttpError(`Request failed: ${resp.status} ${resp.statusText}`, details)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CSR Client Factory
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a client-side HTTP client for Django.
|
||||
*
|
||||
* @param auth - Authentication strategy (Auth.SESSION or Auth.JWT)
|
||||
* @param config - Client configuration
|
||||
* @returns MizanHTTPClient
|
||||
*
|
||||
* @example
|
||||
* // Session-based
|
||||
* const client = createMizanCSRClient(Auth.SESSION)
|
||||
*
|
||||
* @example
|
||||
* // JWT-based
|
||||
* const client = createMizanCSRClient(Auth.JWT, {
|
||||
* getAccessToken: async () => localStorage.getItem('token')
|
||||
* })
|
||||
*/
|
||||
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
|
||||
): MizanHTTPClient {
|
||||
if (!config?.baseUrl) {
|
||||
throw new Error(
|
||||
'baseUrl is required. Pass it via config or use MizanProvider which provides it automatically.'
|
||||
)
|
||||
}
|
||||
|
||||
const getBaseUrl = () => typeof config.baseUrl === 'function' ? config.baseUrl() : config.baseUrl!
|
||||
|
||||
const getHeaders = async (): Promise<Record<string, string>> => {
|
||||
if (auth === Auth.JWT) {
|
||||
const jwtConfig = config as JWTClientConfig
|
||||
const token = await jwtConfig.getAccessToken()
|
||||
if (token) {
|
||||
return { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
// Session auth uses CSRF
|
||||
return { [getCsrfHeaderName()]: getCSRFToken() ?? '' }
|
||||
}
|
||||
|
||||
function resolveUrl(path: string): string {
|
||||
const base = getBaseUrl()
|
||||
// Absolute base URL — use URL constructor
|
||||
if (base.startsWith('http://') || base.startsWith('https://')) {
|
||||
return new URL(path, base).toString()
|
||||
}
|
||||
// Relative base — path is already usable by fetch in a browser
|
||||
return path
|
||||
}
|
||||
|
||||
return {
|
||||
request: async (method, path, data?, headers?) => {
|
||||
const url = resolveUrl(path)
|
||||
const configHeaders = await getHeaders()
|
||||
const build = buildRequest(method, data, { ...configHeaders, ...headers })
|
||||
return fetch(url, build.request)
|
||||
},
|
||||
|
||||
json: async <T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T> => {
|
||||
const url = resolveUrl(path)
|
||||
const configHeaders = await getHeaders()
|
||||
const build = buildRequest(method, data, { ...configHeaders, ...headers })
|
||||
const resp = await fetch(url, build.request)
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await buildHttpError(resp, url)
|
||||
}
|
||||
|
||||
return resp.json()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Internal Backend URL Resolution
|
||||
// =============================================================================
|
||||
|
||||
function getInternalBackendUrl(override?: string): string {
|
||||
if (override) return override
|
||||
throw new Error(
|
||||
'baseUrl is required for SSR client. Pass it via config.'
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SSR Client Factory
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if cookies is a CookieGetter interface.
|
||||
*/
|
||||
function isCookieGetter(cookies: SSRCookies): cookies is CookieGetter {
|
||||
return typeof (cookies as CookieGetter).get === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract CSRF token and cookie header from SSRCookies.
|
||||
*/
|
||||
function extractCookies(cookies: SSRCookies): { csrf: string; cookieHeader: string } {
|
||||
if (isCookieGetter(cookies)) {
|
||||
return {
|
||||
csrf: cookies.get(getCsrfCookieName())?.value ?? '',
|
||||
cookieHeader: cookies.getAll().map(c => `${c.name}=${c.value}`).join('; ')
|
||||
}
|
||||
}
|
||||
return cookies
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a server-side HTTP client for Django.
|
||||
* Used in SSR contexts (Next.js server components, server actions, etc.)
|
||||
*
|
||||
* @param config - SSR client configuration with cookies
|
||||
* @returns MizanHTTPClient
|
||||
*
|
||||
* @example
|
||||
* // Next.js server component
|
||||
* import { cookies } from 'next/headers'
|
||||
*
|
||||
* const client = createMizanSSRClient({ cookies: await cookies() })
|
||||
*/
|
||||
// Re-export auth types for non-React usage
|
||||
export type {
|
||||
BaseUser,
|
||||
AuthDetails,
|
||||
AuthRoutes,
|
||||
JWTTokens,
|
||||
JWTConfig,
|
||||
JWTState,
|
||||
} from './types'
|
||||
|
||||
export function createMizanSSRClient(config: SSRClientConfig): MizanHTTPClient {
|
||||
const baseUrl = getInternalBackendUrl(config.baseUrl)
|
||||
const { csrf, cookieHeader } = extractCookies(config.cookies)
|
||||
|
||||
return {
|
||||
request: async (method, path, data?, headers?) => {
|
||||
const url = new URL(path, baseUrl)
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
[getCsrfHeaderName()]: csrf,
|
||||
'Cookie': cookieHeader,
|
||||
...headers,
|
||||
}
|
||||
|
||||
let body: BodyInit | undefined
|
||||
if (data && !['GET', 'HEAD'].includes(method.toUpperCase())) {
|
||||
if (data instanceof FormData) {
|
||||
body = data
|
||||
} else {
|
||||
body = JSON.stringify(data)
|
||||
requestHeaders['Content-Type'] = 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
method: method.toUpperCase(),
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
},
|
||||
|
||||
json: async <T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T> => {
|
||||
const url = new URL(path, baseUrl)
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
[getCsrfHeaderName()]: csrf,
|
||||
'Cookie': cookieHeader,
|
||||
...headers,
|
||||
}
|
||||
|
||||
let body: BodyInit | undefined
|
||||
if (data && !['GET', 'HEAD'].includes(method.toUpperCase())) {
|
||||
if (data instanceof FormData) {
|
||||
body = data
|
||||
} else {
|
||||
body = JSON.stringify(data)
|
||||
requestHeaders['Content-Type'] = 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: method.toUpperCase(),
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await buildHttpError(resp, url)
|
||||
}
|
||||
|
||||
const contentType = resp.headers.get('content-type') ?? ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error(`Expected JSON response but got ${contentType}`)
|
||||
}
|
||||
|
||||
return resp.json()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SSR Session Initialization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Response from the session initialization endpoint.
|
||||
*/
|
||||
interface SessionInitResponse {
|
||||
csrfToken: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a Django session exists before making SSR requests.
|
||||
*
|
||||
* On first visit, the user has no cookies. This function pings Django to
|
||||
* establish a session and get a CSRF token, which can then be used for
|
||||
* subsequent hydration requests in the same SSR request chain.
|
||||
*
|
||||
* Note: Browser cookie forwarding is handled by Next.js middleware, not this
|
||||
* function. This function only ensures cookies exist for SSR data fetching.
|
||||
*
|
||||
* @param config - SSR client configuration with cookies
|
||||
* @returns Object with csrf token and cookie header for use in SSR requests
|
||||
*
|
||||
* @example
|
||||
* // In layout.tsx
|
||||
* const cookieStore = await cookies()
|
||||
* const session = await ensureMizanSession({ cookies: cookieStore })
|
||||
* const client = createMizanSSRClient({
|
||||
* cookies: { csrf: session.csrf, cookieHeader: session.cookieHeader }
|
||||
* })
|
||||
*/
|
||||
export async function ensureMizanSession(config: SSRClientConfig): Promise<{
|
||||
csrf: string
|
||||
cookieHeader: string
|
||||
}> {
|
||||
const baseUrl = getInternalBackendUrl(config.baseUrl)
|
||||
const { csrf: existingCsrf, cookieHeader: existingCookies } = extractCookies(config.cookies)
|
||||
|
||||
// If we already have a CSRF token, just return existing cookies
|
||||
if (existingCsrf) {
|
||||
return { csrf: existingCsrf, cookieHeader: existingCookies }
|
||||
}
|
||||
|
||||
// No CSRF token - need to initialize session
|
||||
const url = new URL('/api/mizan/session/', baseUrl)
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Cookie': existingCookies,
|
||||
},
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
console.error('[mizan] Failed to initialize session:', resp.status, resp.statusText)
|
||||
return { csrf: '', cookieHeader: existingCookies }
|
||||
}
|
||||
|
||||
// Extract CSRF token from response body
|
||||
const data: SessionInitResponse = await resp.json()
|
||||
|
||||
// Extract Set-Cookie headers to build updated cookie string for SSR chain
|
||||
const setCookieHeaders = resp.headers.getSetCookie?.() ?? []
|
||||
const newCookies = setCookieHeaders.map(c => c.split(';')[0]).join('; ')
|
||||
const combinedCookies = existingCookies
|
||||
? `${existingCookies}; ${newCookies}`
|
||||
: newCookies
|
||||
|
||||
return {
|
||||
csrf: data.csrfToken,
|
||||
cookieHeader: combinedCookies,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Server Function HTTP Call
|
||||
// =============================================================================
|
||||
|
||||
// Re-export error types from the canonical location
|
||||
export type { FunctionErrorResponse } from '../errors'
|
||||
import { MizanError, type FunctionErrorResponse } from '../errors'
|
||||
|
||||
/**
|
||||
* Success response from a server function
|
||||
*/
|
||||
export interface FunctionSuccessResponse<T> {
|
||||
result: T
|
||||
invalidate?: Array<string | { context: string; params: Record<string, any> }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for server function responses
|
||||
*/
|
||||
export type FunctionResponse<T> = FunctionSuccessResponse<T> | FunctionErrorResponse
|
||||
|
||||
58
frontends/mizan-react/src/client/react.ts
Normal file
58
frontends/mizan-react/src/client/react.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useJWT } from '../jwt/JWTContext'
|
||||
import {
|
||||
createMizanCSRClient,
|
||||
Auth,
|
||||
type MizanHTTPClient,
|
||||
type CSRClientConfig,
|
||||
} from './index'
|
||||
|
||||
// Re-export everything from main entry for convenience
|
||||
export * from './index'
|
||||
export * from '../jwt/JWTContext'
|
||||
export type * from './types'
|
||||
|
||||
/**
|
||||
* React hook that returns a client-side Django HTTP client.
|
||||
*
|
||||
* For SESSION auth, creates a session-based client with CSRF handling.
|
||||
* For JWT auth, automatically wires up the JWTContext from mizan/jwt.
|
||||
*
|
||||
* @param auth - Authentication strategy (Auth.SESSION or Auth.JWT)
|
||||
* @param config - Optional client configuration
|
||||
* @returns MizanHTTPClient
|
||||
*
|
||||
* @example
|
||||
* // Session-based
|
||||
* const client = useMizanCSRClient(Auth.SESSION)
|
||||
* const user = await client.json('GET', '/api/accounts/me/')
|
||||
*
|
||||
* @example
|
||||
* // JWT-based (requires JWTContext from mizan/jwt)
|
||||
* const client = useMizanCSRClient(Auth.JWT)
|
||||
* const user = await client.json('GET', '/api/accounts/me/')
|
||||
*/
|
||||
export function useMizanCSRClient(auth: Auth, config?: CSRClientConfig): MizanHTTPClient {
|
||||
// Always call useJWT (React hooks must be unconditional)
|
||||
// Returns null when outside JWTContext
|
||||
const jwtContext = useJWT()
|
||||
|
||||
return useMemo(() => {
|
||||
if (auth === Auth.JWT) {
|
||||
if (!jwtContext?.getAccessToken) {
|
||||
throw new Error(
|
||||
'useMizanCSRClient(Auth.JWT) requires JWTContext from mizan/jwt. ' +
|
||||
'Wrap your component in JWTContext to use JWT authentication.'
|
||||
)
|
||||
}
|
||||
return createMizanCSRClient(Auth.JWT, {
|
||||
...config,
|
||||
getAccessToken: jwtContext.getAccessToken,
|
||||
})
|
||||
}
|
||||
|
||||
return createMizanCSRClient(Auth.SESSION, config)
|
||||
}, [auth, config, jwtContext?.getAccessToken])
|
||||
}
|
||||
66
frontends/mizan-react/src/client/types.ts
Normal file
66
frontends/mizan-react/src/client/types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Base user type - extend this for your app's user model.
|
||||
*/
|
||||
export interface BaseUser {
|
||||
id?: number | string
|
||||
email?: string
|
||||
username?: string
|
||||
is_staff?: boolean
|
||||
is_superuser?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth state derived from user.
|
||||
*/
|
||||
export interface AuthDetails {
|
||||
isAuthenticated: boolean
|
||||
isStaff: boolean
|
||||
isSuperuser: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for route guards.
|
||||
*/
|
||||
export interface AuthRoutes {
|
||||
login: string
|
||||
authenticated: string
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT token structure.
|
||||
*/
|
||||
export interface JWTTokens {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt: number // Unix timestamp in ms
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT endpoint configuration.
|
||||
*/
|
||||
export interface JWTConfig {
|
||||
/** Base URL for API calls (default: '' - use relative URLs) */
|
||||
baseUrl?: string
|
||||
/** mizan server function endpoint (default: /api/mizan/call/) */
|
||||
endpoint?: string
|
||||
/** Seconds before expiry to trigger refresh (default: 30) */
|
||||
refreshBuffer?: number
|
||||
/** Auto-obtain tokens on mount (default: true) */
|
||||
autoObtain?: boolean
|
||||
/** Auto-refresh tokens before expiry (default: true) */
|
||||
autoRefresh?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT state and methods.
|
||||
*/
|
||||
export interface JWTState {
|
||||
tokens: JWTTokens | null
|
||||
isLoading: boolean
|
||||
error: Error | null
|
||||
obtainTokens: () => Promise<JWTTokens | null>
|
||||
refreshTokens: () => Promise<JWTTokens | null>
|
||||
clearTokens: () => void
|
||||
getAccessToken: () => Promise<string | null>
|
||||
}
|
||||
749
frontends/mizan-react/src/context.tsx
Normal file
749
frontends/mizan-react/src/context.tsx
Normal file
@@ -0,0 +1,749 @@
|
||||
'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 {
|
||||
createMizanCSRClient,
|
||||
Auth,
|
||||
type FunctionResponse,
|
||||
} from 'mizan/client'
|
||||
import { useJWT } from './jwt'
|
||||
import { MizanError, type ErrorCode, type FunctionErrorResponse } from './errors'
|
||||
|
||||
import { getCSRFToken } from './utils'
|
||||
|
||||
// ============================================================================
|
||||
// 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 createMizanCSRClient(Auth.JWT, {
|
||||
baseUrl,
|
||||
getAccessToken: jwt.getAccessToken,
|
||||
})
|
||||
}
|
||||
return createMizanCSRClient(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 MizanError
|
||||
if (e instanceof RPCError) {
|
||||
throw new MizanError({
|
||||
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 = await response.json()
|
||||
|
||||
if (data.error) {
|
||||
throw new MizanError(data as FunctionErrorResponse)
|
||||
}
|
||||
|
||||
// Server-driven invalidation: process the invalidate array
|
||||
if (data.invalidate && Array.isArray(data.invalidate)) {
|
||||
for (const entry of data.invalidate) {
|
||||
if (typeof entry === 'string') {
|
||||
const provider = contextProvidersRef.current.get(entry)
|
||||
if (provider) provider.refetch()
|
||||
} else if (entry.context) {
|
||||
const provider = contextProvidersRef.current.get(entry.context)
|
||||
if (provider) provider.refetch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data.result as TOutput
|
||||
},
|
||||
[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 via GET /ctx/<name>/
|
||||
const refreshContext = useCallback(
|
||||
async (name: string): Promise<void> => {
|
||||
try {
|
||||
const response = await httpClient.request('GET', `${baseUrl}/ctx/${name}/`)
|
||||
const data = await response.json()
|
||||
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> => {
|
||||
// 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))
|
||||
)
|
||||
},
|
||||
[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
|
||||
107
frontends/mizan-react/src/errors.ts
Normal file
107
frontends/mizan-react/src/errors.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Mizan Server Error Types
|
||||
*
|
||||
* Typed errors for server function failures.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Error codes returned by the server
|
||||
*/
|
||||
export type ErrorCode =
|
||||
| 'NOT_FOUND'
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'BAD_REQUEST'
|
||||
| 'INTERNAL_ERROR'
|
||||
| 'NOT_IMPLEMENTED'
|
||||
|
||||
/**
|
||||
* Error response structure from the server
|
||||
*/
|
||||
export interface FunctionErrorResponse {
|
||||
error: true
|
||||
code: ErrorCode
|
||||
message: string
|
||||
details?: {
|
||||
fields?: Record<string, string[]>
|
||||
required?: string[]
|
||||
type?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a server function call fails
|
||||
*/
|
||||
export class MizanError extends Error {
|
||||
/**
|
||||
* Error code from the server
|
||||
*/
|
||||
readonly code: ErrorCode
|
||||
|
||||
/**
|
||||
* Additional error details
|
||||
*/
|
||||
readonly details?: FunctionErrorResponse['details']
|
||||
|
||||
/**
|
||||
* The original error response
|
||||
*/
|
||||
readonly response: FunctionErrorResponse
|
||||
|
||||
constructor(response: FunctionErrorResponse) {
|
||||
super(response.message)
|
||||
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, MizanError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a validation error
|
||||
*/
|
||||
isValidationError(): boolean {
|
||||
return this.code === 'VALIDATION_ERROR'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an authentication error
|
||||
*/
|
||||
isAuthError(): boolean {
|
||||
return this.code === 'UNAUTHORIZED' || this.code === 'FORBIDDEN'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a not found error
|
||||
*/
|
||||
isNotFound(): boolean {
|
||||
return this.code === 'NOT_FOUND'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field-level validation errors (if this is a validation error)
|
||||
*/
|
||||
getFieldErrors(): Record<string, string[]> | null {
|
||||
if (this.code === 'VALIDATION_ERROR' && this.details?.fields) {
|
||||
return this.details.fields
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error for a specific field
|
||||
*/
|
||||
getFieldError(field: string): string | null {
|
||||
const errors = this.getFieldErrors()
|
||||
if (errors && errors[field]?.length > 0) {
|
||||
return errors[field][0]
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
1171
frontends/mizan-react/src/forms.ts
Normal file
1171
frontends/mizan-react/src/forms.ts
Normal file
File diff suppressed because it is too large
Load Diff
126
frontends/mizan-react/src/index.ts
Normal file
126
frontends/mizan-react/src/index.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* mizan — Server Functions Client
|
||||
*
|
||||
* 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
|
||||
* import { useMizan, useMizanContext, useMizanCall } from 'mizan'
|
||||
*
|
||||
* 2. Generated layer (@/api) — Typed project-specific API
|
||||
* import { useCurrentUser, useUpdateProfile } from '@/api'
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// React Context & Hooks (primary API)
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
// Provider
|
||||
MizanProvider,
|
||||
type MizanProviderProps,
|
||||
type MizanHydration,
|
||||
|
||||
// Hooks
|
||||
useMizan,
|
||||
useMizanContext,
|
||||
useMizanCall,
|
||||
useMizanStatus,
|
||||
usePush,
|
||||
|
||||
// Types
|
||||
type MizanContextValue,
|
||||
type ConnectionStatus,
|
||||
type PushMessage,
|
||||
type PushListener,
|
||||
type ContextStore,
|
||||
type Transport,
|
||||
} from './context'
|
||||
|
||||
// ============================================================================
|
||||
// HTTP Client
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
createMizanCSRClient,
|
||||
createMizanSSRClient,
|
||||
ensureMizanSession,
|
||||
Auth,
|
||||
type MizanHTTPClient,
|
||||
type CSRClientConfig,
|
||||
type JWTClientConfig,
|
||||
type SSRClientConfig,
|
||||
} from './client/'
|
||||
|
||||
// ============================================================================
|
||||
// Errors
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
MizanError,
|
||||
type FunctionErrorResponse,
|
||||
type ErrorCode,
|
||||
} from './errors'
|
||||
|
||||
// ============================================================================
|
||||
// Forms
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
useMizanFormCore,
|
||||
type MizanFormState,
|
||||
type FormSchema,
|
||||
type FormErrors,
|
||||
type FormOptions,
|
||||
type FormSubmitResult,
|
||||
type FormCoreConfig,
|
||||
useMizanFormsetCore,
|
||||
type MizanFormsetState,
|
||||
type FormsetSchema,
|
||||
type FormsetErrors,
|
||||
type FormsetCoreConfig,
|
||||
type FormsetSubmitResult,
|
||||
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'
|
||||
230
frontends/mizan-react/src/jwt/JWTContext.tsx
Normal file
230
frontends/mizan-react/src/jwt/JWTContext.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type { JWTTokens, JWTConfig, JWTState } from '../client/types'
|
||||
import { getCSRFToken } from '../utils'
|
||||
|
||||
const Context = createContext<JWTState | null>(null)
|
||||
|
||||
const DEFAULT_CONFIG: Required<JWTConfig> = {
|
||||
baseUrl: '',
|
||||
endpoint: '/api/mizan/call/',
|
||||
refreshBuffer: 30,
|
||||
autoObtain: true,
|
||||
autoRefresh: true,
|
||||
}
|
||||
|
||||
interface JWTContextProps {
|
||||
children: ReactNode
|
||||
config?: JWTConfig
|
||||
}
|
||||
|
||||
export function JWTContext({ children, config }: JWTContextProps) {
|
||||
const cfg = { ...DEFAULT_CONFIG, ...config }
|
||||
|
||||
const [tokens, setTokens] = useState<JWTTokens | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Helper to call server functions
|
||||
const callServerFunction = useCallback(async (fn: string, args: Record<string, unknown> = {}) => {
|
||||
const url = cfg.baseUrl ? `${cfg.baseUrl}${cfg.endpoint}` : cfg.endpoint
|
||||
const csrfToken = getCSRFToken()
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
|
||||
},
|
||||
credentials: 'include', // Include session cookie for CSRF
|
||||
body: JSON.stringify({ fn, args }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error) {
|
||||
const err = new Error(data.message || 'Server function failed')
|
||||
;(err as any).code = data.code
|
||||
;(err as any).details = data.details
|
||||
throw err
|
||||
}
|
||||
|
||||
return data.data
|
||||
}, [cfg.baseUrl, cfg.endpoint])
|
||||
|
||||
// Obtain tokens from session
|
||||
const obtainTokens = useCallback(async (): Promise<JWTTokens | null> => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await callServerFunction('jwt_obtain')
|
||||
const newTokens: JWTTokens = {
|
||||
accessToken: result.access_token,
|
||||
refreshToken: result.refresh_token,
|
||||
expiresAt: Date.now() + result.expires_in * 1000,
|
||||
}
|
||||
|
||||
setTokens(newTokens)
|
||||
return newTokens
|
||||
} catch (err: any) {
|
||||
// FORBIDDEN means not authenticated - expected, not an error
|
||||
if (err.code === 'FORBIDDEN') {
|
||||
setTokens(null)
|
||||
return null
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
setError(error)
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [callServerFunction])
|
||||
|
||||
// Refresh tokens
|
||||
const refreshTokens = useCallback(async (): Promise<JWTTokens | null> => {
|
||||
if (!tokens?.refreshToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await callServerFunction('jwt_refresh', {
|
||||
refresh_token: tokens.refreshToken,
|
||||
})
|
||||
const newTokens: JWTTokens = {
|
||||
accessToken: result.access_token,
|
||||
refreshToken: result.refresh_token,
|
||||
expiresAt: Date.now() + result.expires_in * 1000,
|
||||
}
|
||||
|
||||
setTokens(newTokens)
|
||||
return newTokens
|
||||
} catch (err: any) {
|
||||
// FORBIDDEN means refresh token invalid/expired - clear tokens
|
||||
if (err.code === 'FORBIDDEN') {
|
||||
setTokens(null)
|
||||
return null
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
setError(error)
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [tokens?.refreshToken, callServerFunction])
|
||||
|
||||
// Clear tokens
|
||||
const clearTokens = useCallback(() => {
|
||||
setTokens(null)
|
||||
setError(null)
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current)
|
||||
refreshTimeoutRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Get access token (refresh if needed)
|
||||
const getAccessToken = useCallback(async (): Promise<string | null> => {
|
||||
if (!tokens) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if token needs refresh
|
||||
const bufferMs = cfg.refreshBuffer * 1000
|
||||
if (tokens.expiresAt - Date.now() < bufferMs) {
|
||||
const newTokens = await refreshTokens()
|
||||
return newTokens?.accessToken ?? null
|
||||
}
|
||||
|
||||
return tokens.accessToken
|
||||
}, [tokens, cfg.refreshBuffer, refreshTokens])
|
||||
|
||||
// Auto-obtain on mount
|
||||
useEffect(() => {
|
||||
if (!cfg.autoObtain) return
|
||||
obtainTokens()
|
||||
}, [cfg.autoObtain, obtainTokens])
|
||||
|
||||
// Auto-refresh before expiry
|
||||
useEffect(() => {
|
||||
if (!cfg.autoRefresh || !tokens) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear existing timeout
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Schedule refresh
|
||||
const bufferMs = cfg.refreshBuffer * 1000
|
||||
const timeUntilRefresh = tokens.expiresAt - Date.now() - bufferMs
|
||||
|
||||
if (timeUntilRefresh > 0) {
|
||||
refreshTimeoutRef.current = setTimeout(() => {
|
||||
refreshTokens()
|
||||
}, timeUntilRefresh)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [cfg.autoRefresh, cfg.refreshBuffer, tokens, refreshTokens])
|
||||
|
||||
const value: JWTState = {
|
||||
tokens,
|
||||
isLoading,
|
||||
error,
|
||||
obtainTokens,
|
||||
refreshTokens,
|
||||
clearTokens,
|
||||
getAccessToken,
|
||||
}
|
||||
|
||||
return <Context value={value}>{children}</Context>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access JWT state and methods.
|
||||
*
|
||||
* When used outside JWTContext, returns null. This allows
|
||||
* conditional JWT usage (e.g., useDjangoApi({ jwt: true }))
|
||||
* without requiring JWTContext to always be present.
|
||||
*/
|
||||
export function useJWT(): JWTState | null {
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access JWT state with a guarantee it exists.
|
||||
* Throws if used outside JWTContext.
|
||||
*/
|
||||
export function useJWTRequired(): JWTState {
|
||||
const context = useContext(Context)
|
||||
if (!context) {
|
||||
throw new Error('useJWTRequired must be used within JWTContext')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/** Check if JWT is available (tokens obtained) */
|
||||
export function useJWTReady(): boolean {
|
||||
const jwt = useJWT()
|
||||
if (!jwt) return false
|
||||
return !jwt.isLoading && jwt.tokens !== null
|
||||
}
|
||||
152
frontends/mizan-react/src/jwt/__tests__/JWTContext.test.tsx
Normal file
152
frontends/mizan-react/src/jwt/__tests__/JWTContext.test.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Integration tests for JWT Context
|
||||
*
|
||||
* These tests call the REAL backend - no mocks.
|
||||
* Backend must be running: docker-compose up
|
||||
*
|
||||
* Run with: RUN_INTEGRATION_TESTS=true npm run test
|
||||
*
|
||||
* Note: Most JWT operations require an authenticated session.
|
||||
* Tests that require authentication verify 401 handling (expected for anonymous users).
|
||||
*/
|
||||
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { ReactNode } from 'react'
|
||||
import { JWTContext, useJWT, useJWTRequired } from '../JWTContext'
|
||||
import { describeIntegration, BACKEND_URL } from '../../testing'
|
||||
|
||||
function createWrapper(config?: Parameters<typeof JWTContext>[0]['config']) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<JWTContext config={{
|
||||
autoObtain: false,
|
||||
baseUrl: BACKEND_URL,
|
||||
...config
|
||||
}}>
|
||||
{children}
|
||||
</JWTContext>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
describeIntegration('JWTContext (integration)', () => {
|
||||
describe('Hook behavior outside provider', () => {
|
||||
it('should return null when useJWT used outside JWTContext', () => {
|
||||
const { result } = renderHook(() => useJWT())
|
||||
expect(result.current).toBeNull()
|
||||
})
|
||||
|
||||
it('should throw when useJWTRequired used outside JWTContext', () => {
|
||||
// Suppress console.error for this test
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useJWTRequired())
|
||||
}).toThrow('useJWTRequired must be used within JWTContext')
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Token operations with real backend', () => {
|
||||
it('should handle 401 when obtaining tokens as anonymous user', async () => {
|
||||
const { result } = renderHook(() => useJWTRequired(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
const tokens = await result.current.obtainTokens()
|
||||
// Anonymous users get 401, which returns null (not an error)
|
||||
expect(tokens).toBeNull()
|
||||
})
|
||||
|
||||
expect(result.current.tokens).toBeNull()
|
||||
// 401 is graceful - not an error state
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle 401 when refreshing tokens without valid refresh token', async () => {
|
||||
const { result } = renderHook(() => useJWTRequired(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
// Try to refresh without any tokens - should fail gracefully
|
||||
await act(async () => {
|
||||
const tokens = await result.current.refreshTokens()
|
||||
expect(tokens).toBeNull()
|
||||
})
|
||||
|
||||
expect(result.current.tokens).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearTokens (no backend needed)', () => {
|
||||
it('should be safe to call clearTokens when no tokens are set', async () => {
|
||||
// Verifies clearTokens doesn't throw or corrupt state when called
|
||||
// with no tokens present (e.g., during logout when already logged out)
|
||||
const { result } = renderHook(() => useJWTRequired(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.clearTokens()
|
||||
})
|
||||
|
||||
expect(result.current.tokens).toBeNull()
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAccessToken', () => {
|
||||
it('should return null when no tokens available', async () => {
|
||||
const { result } = renderHook(() => useJWTRequired(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
let token: string | null = 'not-null'
|
||||
await act(async () => {
|
||||
token = await result.current.getAccessToken()
|
||||
})
|
||||
|
||||
expect(token).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoObtain with real backend', () => {
|
||||
it('should attempt auto-obtain and handle 401 for anonymous', async () => {
|
||||
const { result } = renderHook(() => useJWTRequired(), {
|
||||
wrapper: createWrapper({ autoObtain: true }),
|
||||
})
|
||||
|
||||
// Auto-obtain will attempt to get tokens but fail for anonymous user
|
||||
await waitFor(() => {
|
||||
// After auto-obtain completes, tokens should be null (401 response)
|
||||
// or the loading state should be done
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Anonymous user won't have tokens
|
||||
expect(result.current.tokens).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Note on authenticated JWT tests:
|
||||
*
|
||||
* To fully test JWT token obtain/refresh, we would need:
|
||||
* 1. An authenticated user session (login first)
|
||||
* 2. Valid CSRF token handling
|
||||
*
|
||||
* These scenarios are better tested in E2E tests (Playwright/Cypress)
|
||||
* where we can:
|
||||
* 1. Navigate to login page
|
||||
* 2. Submit credentials
|
||||
* 3. Then test JWT token flows
|
||||
*
|
||||
* The tests above verify:
|
||||
* - Hook API contract (throws/returns null outside provider)
|
||||
* - Graceful 401 handling (anonymous users)
|
||||
* - State management (clearTokens)
|
||||
* - Integration with real backend (network calls happen)
|
||||
*/
|
||||
79
frontends/mizan-react/src/jwt/__tests__/contract.test.ts
Normal file
79
frontends/mizan-react/src/jwt/__tests__/contract.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Contract Tests for mizan JWT Server Functions
|
||||
*
|
||||
* Validates that the backend schema exports the expected JWT functions.
|
||||
* These tests catch frontend/backend contract mismatches early.
|
||||
*/
|
||||
|
||||
import mizanSchema from '@/api/generated.mizan.schema.json'
|
||||
|
||||
type mizanFunction = {
|
||||
name: string
|
||||
camelName: string
|
||||
hasInput: boolean
|
||||
inputType: string | null
|
||||
outputType: string
|
||||
transport: string
|
||||
}
|
||||
|
||||
function getFunctions(): mizanFunction[] {
|
||||
return (mizanSchema as any)['x-mizan-functions'] ?? []
|
||||
}
|
||||
|
||||
function findFunction(name: string): mizanFunction | undefined {
|
||||
return getFunctions().find(fn => fn.name === name)
|
||||
}
|
||||
|
||||
describe('JWT Server Functions Contract', () => {
|
||||
describe('jwt_obtain', () => {
|
||||
it('should be registered as a server function', () => {
|
||||
const fn = findFunction('jwt_obtain')
|
||||
expect(fn).toBeDefined()
|
||||
expect(fn?.transport).toBe('http')
|
||||
})
|
||||
|
||||
it('should have no input (session-based)', () => {
|
||||
const fn = findFunction('jwt_obtain')
|
||||
expect(fn?.hasInput).toBe(false)
|
||||
})
|
||||
|
||||
it('should return token pair with expected fields', () => {
|
||||
const schemas = (mizanSchema as any).components?.schemas
|
||||
const output = schemas?.jwtObtainOutput
|
||||
|
||||
expect(output).toBeDefined()
|
||||
expect(output.properties.access_token).toBeDefined()
|
||||
expect(output.properties.refresh_token).toBeDefined()
|
||||
expect(output.properties.expires_in).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('jwt_refresh', () => {
|
||||
it('should be registered as a server function', () => {
|
||||
const fn = findFunction('jwt_refresh')
|
||||
expect(fn).toBeDefined()
|
||||
expect(fn?.transport).toBe('http')
|
||||
})
|
||||
|
||||
it('should accept refresh_token as input', () => {
|
||||
const fn = findFunction('jwt_refresh')
|
||||
expect(fn?.hasInput).toBe(true)
|
||||
|
||||
const schemas = (mizanSchema as any).components?.schemas
|
||||
const input = schemas?.jwtRefreshInput
|
||||
|
||||
expect(input).toBeDefined()
|
||||
expect(input.properties.refresh_token).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return token pair with expected fields', () => {
|
||||
const schemas = (mizanSchema as any).components?.schemas
|
||||
const output = schemas?.jwtRefreshOutput
|
||||
|
||||
expect(output).toBeDefined()
|
||||
expect(output.properties.access_token).toBeDefined()
|
||||
expect(output.properties.refresh_token).toBeDefined()
|
||||
expect(output.properties.expires_in).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
34
frontends/mizan-react/src/jwt/__tests__/hooks.test.tsx
Normal file
34
frontends/mizan-react/src/jwt/__tests__/hooks.test.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Unit Tests for JWT Hooks
|
||||
*
|
||||
* Tests hook behavior in isolation (no backend required).
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { ReactNode } from 'react'
|
||||
import { JWTContext, useJWTReady } from '../JWTContext'
|
||||
|
||||
// Wrapper that provides JWTContext
|
||||
function createWrapper(config?: Parameters<typeof JWTContext>[0]['config']) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<JWTContext config={{ autoObtain: false, ...config }}>
|
||||
{children}
|
||||
</JWTContext>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
describe('useJWTReady', () => {
|
||||
it('returns false outside JWTContext', () => {
|
||||
const { result } = renderHook(() => useJWTReady())
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no tokens', () => {
|
||||
const { result } = renderHook(() => useJWTReady(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
})
|
||||
79
frontends/mizan-react/src/jwt/index.ts
Normal file
79
frontends/mizan-react/src/jwt/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* mizan/jwt
|
||||
*
|
||||
* JWT token management via mizan server functions.
|
||||
* Handles token lifecycle: obtain, refresh, clear.
|
||||
*
|
||||
* ## Quick Start
|
||||
*
|
||||
* Use JWTContext in authenticated areas (e.g., inside UserRoute):
|
||||
*
|
||||
* ```tsx
|
||||
* import { JWTContext } from 'mizan/jwt'
|
||||
* import { UserRoute } from 'mizan/allauth'
|
||||
*
|
||||
* function ProtectedPage() {
|
||||
* return (
|
||||
* <UserRoute>
|
||||
* <JWTContext>
|
||||
* <MyProtectedContent />
|
||||
* </JWTContext>
|
||||
* </UserRoute>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Then use JWT-authenticated requests:
|
||||
*
|
||||
* ```tsx
|
||||
* import { useDjangoCSRClient, Auth } from 'mizan/client/react'
|
||||
*
|
||||
* function MyProtectedContent() {
|
||||
* const client = useDjangoCSRClient(Auth.JWT)
|
||||
*
|
||||
* const fetchData = async () => {
|
||||
* const response = await client.request('GET', '/api/protected/')
|
||||
* return response.json()
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## How It Works
|
||||
*
|
||||
* 1. JWTContext calls jwt_obtain server function (via /api/mizan/call/)
|
||||
* 2. If not authenticated, returns FORBIDDEN (tokens stay null)
|
||||
* 3. Client uses getAccessToken() for Bearer token injection
|
||||
* 4. Tokens auto-refresh via jwt_refresh server function
|
||||
* 5. On logout, call clearTokens()
|
||||
*
|
||||
* ## Configuration
|
||||
*
|
||||
* ```tsx
|
||||
* <JWTContext
|
||||
* config={{
|
||||
* endpoint: '/api/mizan/call/', // default
|
||||
* refreshBuffer: 30, // refresh 30s before expiry
|
||||
* autoObtain: true, // obtain on mount
|
||||
* autoRefresh: true, // auto-refresh before expiry
|
||||
* }}
|
||||
* >
|
||||
* ```
|
||||
*
|
||||
* ## Manual Token Management
|
||||
*
|
||||
* ```tsx
|
||||
* import { useJWT } from 'mizan/jwt'
|
||||
*
|
||||
* function LogoutButton() {
|
||||
* const jwt = useJWT()
|
||||
*
|
||||
* const handleLogout = async () => {
|
||||
* await fetch('/api/logout/', { method: 'POST' })
|
||||
* jwt?.clearTokens()
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { JWTContext, useJWT, useJWTRequired, useJWTReady } from './JWTContext'
|
||||
export type { JWTTokens, JWTConfig, JWTState } from '../client/types'
|
||||
42
frontends/mizan-react/src/testing.ts
Normal file
42
frontends/mizan-react/src/testing.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Integration Test Helper
|
||||
*
|
||||
* Integration tests require a running backend: docker-compose up
|
||||
*
|
||||
* To run integration tests:
|
||||
* RUN_INTEGRATION_TESTS=true npm run test
|
||||
*
|
||||
* By default, integration tests are skipped in the regular test run.
|
||||
*/
|
||||
|
||||
export const runIntegrationTests = process.env.RUN_INTEGRATION_TESTS === 'true'
|
||||
|
||||
// Type for Jest's describe function (simplied, avoids needing @types/jest at build time)
|
||||
type DescribeFn = {
|
||||
(name: string, fn: () => void): void
|
||||
skip: (name: string, fn: () => void) => void
|
||||
}
|
||||
|
||||
// Declare global describe from Jest (only available in test environment)
|
||||
declare const describe: DescribeFn
|
||||
|
||||
/**
|
||||
* Use this instead of `describe` for integration test suites that require a backend.
|
||||
* Tests will be skipped unless RUN_INTEGRATION_TESTS=true.
|
||||
*/
|
||||
export const describeIntegration = runIntegrationTests ? describe : describe.skip
|
||||
|
||||
/**
|
||||
* Backend URL from environment or default localhost
|
||||
*/
|
||||
export const BACKEND_URL = (() => {
|
||||
if (!process.env.NEXT_PUBLIC_HOST_URL) {
|
||||
console.warn('[mizan/testing] NEXT_PUBLIC_HOST_URL not set, falling back to http://localhost')
|
||||
}
|
||||
return process.env.NEXT_PUBLIC_HOST_URL || 'http://localhost'
|
||||
})()
|
||||
|
||||
/**
|
||||
* WebSocket URL derived from backend URL
|
||||
*/
|
||||
export const WS_URL = BACKEND_URL.replace(/^http/, 'ws') + '/ws/'
|
||||
29
frontends/mizan-react/src/utils.ts
Normal file
29
frontends/mizan-react/src/utils.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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(new RegExp(`${_csrfCookieName}=([^;]+)`))
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user