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:
2026-05-05 20:55:37 -04:00
parent 6eca514777
commit fe39fcb229
126 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
{
"name": "@mizan/runtime",
"version": "0.1.0",
"description": "Mizan client runtime — context registry, invalidation, fetch. Zero framework dependencies.",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"license": "MIT"
}

View File

@@ -0,0 +1,317 @@
/**
* @mizan/runtime — The client state kernel.
*
* Zero framework dependencies. React, Vue, Svelte — all import from here.
*
* The kernel owns the data. Adapters subscribe and render.
*/
// === Error ===
export class MizanError extends Error {
constructor(public status: number, public body: string) {
super(`Mizan call failed (${status})`)
}
}
// === Configuration ===
interface MizanConfig {
baseUrl: string
getHeaders: () => Record<string, string> | Promise<Record<string, string>>
csrfCookieName: string
csrfHeaderName: string
}
const config: MizanConfig = {
baseUrl: '/api/mizan',
getHeaders: () => ({}),
csrfCookieName: 'csrftoken',
csrfHeaderName: 'X-CSRFToken',
}
export function configure(opts: Partial<MizanConfig>): void {
Object.assign(config, opts)
}
export function getConfig(): Readonly<MizanConfig> {
return config
}
// === CSRF ===
function getCSRFToken(): string | null {
if (typeof document === 'undefined') return null
const match = document.cookie.match(new RegExp(`${config.csrfCookieName}=([^;]+)`))
return match?.[1] ?? null
}
// === Session Init ===
let _sessionReady: Promise<void> | null = null
export function initSession(): Promise<void> {
if (_sessionReady) return _sessionReady
_sessionReady = (async () => {
if (getCSRFToken()) return
for (let attempt = 0; attempt < 3; attempt++) {
try {
await fetch(`${config.baseUrl}/session/`, { credentials: 'include' })
if (getCSRFToken()) return
} catch (e) {
console.warn(`[mizan] Session init attempt ${attempt + 1} failed:`, e)
}
if (attempt < 2) await new Promise(r => setTimeout(r, (attempt + 1) * 100))
}
_sessionReady = null
})()
return _sessionReady
}
// === Context State ===
export type ContextStatus = 'idle' | 'loading' | 'success' | 'error'
export interface ContextState<T = any> {
data: T | null
status: ContextStatus
error: Error | null
}
type Listener = () => void
type ParamKey = string
interface ContextEntry {
params: Record<string, any>
state: ContextState
listeners: Set<Listener>
fetchFn: () => Promise<any>
}
const contexts: Map<string, Map<ParamKey, ContextEntry>> = new Map()
function stableKey(params: Record<string, any>): string {
return JSON.stringify(params, Object.keys(params).sort())
}
/**
* Register a context instance. The kernel owns the fetch lifecycle.
*
* Returns { getState, subscribe, refetch, unregister }.
* Adapters call subscribe() to get notified on state changes.
*/
export function registerContext(
name: string,
params: Record<string, any>,
fetchFn: () => Promise<any>,
initialData?: any,
): {
getState: () => ContextState
subscribe: (listener: Listener) => () => void
refetch: () => Promise<void>
unregister: () => void
} {
if (!contexts.has(name)) contexts.set(name, new Map())
const key = stableKey(params)
const map = contexts.get(name)!
// Reuse existing entry if same key is re-registered (React Strict Mode)
let entry = map.get(key)
if (!entry) {
entry = {
params,
state: {
data: initialData ?? null,
status: initialData ? 'success' : 'idle',
error: null,
},
listeners: new Set(),
fetchFn,
}
map.set(key, entry)
} else {
// Update fetchFn in case closure changed
entry.fetchFn = fetchFn
}
const self = entry
function notify() {
self.listeners.forEach(l => l())
}
async function refetch() {
self.state = { ...self.state, status: 'loading', error: null }
notify()
try {
const data = await self.fetchFn()
self.state = { data, status: 'success', error: null }
} catch (e) {
self.state = { ...self.state, status: 'error', error: e as Error }
}
notify()
}
return {
getState: () => self.state,
subscribe: (listener: Listener) => {
self.listeners.add(listener)
return () => self.listeners.delete(listener)
},
refetch,
unregister: () => {
self.listeners.clear()
map.delete(key)
},
}
}
// === Invalidation ===
const pending: Set<string> = new Set()
const pendingScoped: Array<{ context: string; params: Record<string, any> }> = []
let scheduled = false
export function invalidate(context: string, params?: Record<string, any>): void {
if (params) {
pendingScoped.push({ context, params })
} else {
pending.add(context)
}
if (!scheduled) {
scheduled = true
queueMicrotask(flush)
}
}
function flush(): void {
// Broad invalidations — refetch all instances
for (const name of pending) {
const entries = contexts.get(name)
if (entries) {
entries.forEach(entry => {
entry.state = { ...entry.state, status: 'loading', error: null }
entry.listeners.forEach(l => l())
entry.fetchFn().then(data => {
entry.state = { data, status: 'success', error: null }
entry.listeners.forEach(l => l())
}).catch(err => {
entry.state = { ...entry.state, status: 'error', error: err }
entry.listeners.forEach(l => l())
})
})
}
}
// Scoped invalidations — refetch matching params
for (const { context: name, params } of pendingScoped) {
if (pending.has(name)) continue
const entries = contexts.get(name)
if (!entries) continue
const key = stableKey(params)
const entry = entries.get(key)
if (entry) {
entry.state = { ...entry.state, status: 'loading', error: null }
entry.listeners.forEach(l => l())
entry.fetchFn().then(data => {
entry.state = { data, status: 'success', error: null }
entry.listeners.forEach(l => l())
}).catch(err => {
entry.state = { ...entry.state, status: 'error', error: err }
entry.listeners.forEach(l => l())
})
}
}
pending.clear()
pendingScoped.length = 0
scheduled = false
}
// === Fetch ===
async function fetchWithRetry(
input: RequestInfo | URL,
init?: RequestInit,
retries = 2,
): Promise<Response> {
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(input, init)
if (res.ok || (res.status >= 400 && res.status < 500)) return res
if (attempt >= retries) return res
} catch (e) {
if (attempt >= retries) throw e
}
await new Promise(r => setTimeout(r, (attempt + 1) * 200))
}
}
async function resolveHeaders(): Promise<Record<string, string>> {
await initSession()
const custom = await config.getHeaders()
const csrf = getCSRFToken()
return {
...custom,
...(csrf ? { [config.csrfHeaderName]: csrf } : {}),
'Accept': 'application/json',
}
}
export async function mizanFetch(
contextName: string,
params?: Record<string, any>,
): Promise<any> {
const url = new URL(
`${config.baseUrl}/ctx/${contextName}/`,
typeof globalThis.location !== 'undefined' ? globalThis.location.origin : 'http://localhost',
)
if (params) {
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, String(v))
}
}
const headers = await resolveHeaders()
const res = await fetchWithRetry(url.toString(), { headers, credentials: 'same-origin' })
if (!res.ok) throw new MizanError(res.status, await res.text())
return res.json()
}
export async function mizanCall(
functionName: string,
args: Record<string, any>,
): Promise<any> {
const headers = await resolveHeaders()
headers['Content-Type'] = 'application/json'
const res = await fetch(`${config.baseUrl}/call/`, {
method: 'POST',
headers,
credentials: 'same-origin',
body: JSON.stringify({ fn: functionName, args }),
})
if (!res.ok) throw new MizanError(res.status, await res.text())
const data = await res.json()
// Server-driven invalidation
if (data.invalidate) {
for (const entry of data.invalidate) {
if (typeof entry === 'string') {
invalidate(entry)
} else {
invalidate(entry.context, entry.params)
}
}
}
return data.result
}

2
frontends/mizan-react/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

View File

@@ -0,0 +1,103 @@
# @rythazhur/mizan (TypeScript)
React client for the mizan framework. See the [monorepo root](../README.md) for full documentation.
## Install
```bash
npm install @rythazhur/mizan@git+https://git.impactsoundworks.com/isw/mizan.git#workspace=react
```
## Usage
You don't use this package directly. You use the **generated hooks**.
### 1. Configure
```js
// django.config.mjs
export default {
source: {
django: {
managePath: '../backend/manage.py',
command: ['uv', 'run', 'python'],
},
},
output: 'src/api/generated.ts',
}
```
### 2. Generate
```bash
npx mizan-generate # once
npx mizan-generate --watch # dev mode
```
### 3. Wrap your app
```tsx
import { DjangoContext } from '@/api'
<DjangoContext>
<App />
</DjangoContext>
```
`DjangoContext` is the only provider you need. It handles HTTP, WebSocket, CSRF, session init, context auto-fetching, and channel connections.
### 4. Use generated hooks
```tsx
import { useCurrentUser, useEcho, useContactForm, useChatChannel } from '@/api'
// Context (SSR-hydrated, auto-refreshed)
const user = useCurrentUser()
// Server function
const echo = useEcho()
const result = await echo({ text: 'hello' })
// Form (Zod + server validation)
const form = useContactForm()
form.set('email', 'test@example.com')
await form.submit()
// Channel (WebSocket)
const chat = useChatChannel({ room: 'general' })
chat.send({ text: 'hello' })
chat.messages // typed, reactive
```
## Generated Files
| File | Contents |
|------|----------|
| `generated.django.tsx` | `DjangoContext` + typed hooks |
| `generated.mizan.ts` | Pydantic types |
| `generated.forms.ts` | Form hooks with Zod |
| `generated.channels.hooks.tsx` | Channel hooks |
| `index.ts` | Re-exports everything |
## Sub-exports
| Import | When to use |
|--------|------------|
| `@rythazhur/mizan` | Core: mizanProvider, hooks, forms, errors |
| `@rythazhur/mizan/channels` | WebSocket channels |
| `@rythazhur/mizan/jwt` | JWT token management |
| `@rythazhur/mizan/client` | HTTP clients (CSR/SSR) |
| `@rythazhur/mizan/allauth` | Allauth UI components |
These are **library internals** used by the generated code. You should import from `@/api` (your generated index), not from the library directly.
## Running Tests
```bash
# Unit tests (Vitest, jsdom)
npm test
# E2E tests (Playwright, real browser)
# Requires Docker backend running
npx playwright test
```

View File

@@ -0,0 +1,85 @@
{
"name": "@rythazhur/mizan",
"version": "0.1.1",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./client": {
"types": "./dist/client/index.d.ts",
"import": "./dist/client/index.js"
},
"./client/react": {
"types": "./dist/client/react.d.ts",
"import": "./dist/client/react.js"
},
"./client/nextjs": {
"types": "./dist/client/nextjs.d.ts",
"import": "./dist/client/nextjs.js"
},
"./channels": {
"types": "./dist/channels/index.d.ts",
"import": "./dist/channels/index.js"
},
"./jwt": {
"types": "./dist/jwt/index.d.ts",
"import": "./dist/jwt/index.js"
},
"./allauth": {
"types": "./dist/allauth/index.d.ts",
"import": "./dist/allauth/index.js"
},
"./allauth/nextjs": {
"types": "./dist/allauth/nextjs.d.ts",
"import": "./dist/allauth/nextjs.js"
}
},
"bin": {
"mizan-generate": "./dist/generator/cli.mjs"
},
"scripts": {
"build": "tsc -p tsconfig.build.json && node -e \"require('fs').cpSync('src/generator','dist/generator',{recursive:true})\"",
"dev": "tsc -p tsconfig.build.json --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "RUN_INTEGRATION_TESTS=true NEXT_PUBLIC_HOST_URL=http://localhost:8000 vitest run",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"next": {
"optional": true
}
},
"dependencies": {
"ws": "^8.19.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@simplewebauthn/browser": "^13.2.2",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/ws": "^8.5.0",
"jsdom": "^25.0.0",
"next": "^16.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0",
"zod": "^4.3.6"
},
"optionalDependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.0.0",
"openapi-typescript": "^7.0.0"
}
}

View 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')
})
})
})

View 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()
})
})
})

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

View 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')
})
})

View 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()
})
})

View 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')
})
})
})

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

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

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

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

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

View 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

View 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

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

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

View 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

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

File diff suppressed because it is too large Load Diff

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

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

View 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)
*/

View 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()
})
})
})

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

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

View 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/'

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

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"mizan": ["./src/index.ts"],
"mizan/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["src/**/__tests__/**", "src/**/*.test.*"]
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"skipLibCheck": true,
"types": ["vitest/globals"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,24 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
resolve: {
alias: {
'mizan/channels': path.resolve(__dirname, 'src/channels/index.ts'),
'mizan/client/react': path.resolve(__dirname, 'src/client/react.ts'),
'mizan/client': path.resolve(__dirname, 'src/client/index.ts'),
'mizan/jwt': path.resolve(__dirname, 'src/jwt/index.ts'),
'mizan': path.resolve(__dirname, 'src/index.ts'),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
exclude: [
// Requires @/api/generated.mizan.schema.json from consuming project
'src/jwt/__tests__/contract.test.ts',
],
},
})

View File

@@ -0,0 +1,6 @@
import { vi } from 'vitest'
import '@testing-library/jest-dom/vitest'
// Jest compatibility: existing tests use jest.fn(), jest.spyOn(), jest.mock()
// Vitest's `vi` object has the same API, so we alias it globally.
;(globalThis as any).jest = vi

View File

@@ -0,0 +1,11 @@
{
"name": "@mizan/svelte",
"version": "0.1.0",
"description": "Mizan Svelte adapter — stores generated from @mizan/runtime.",
"type": "module",
"peerDependencies": {
"svelte": ">=4",
"@mizan/runtime": ">=0.1.0"
},
"license": "MIT"
}

View File

@@ -0,0 +1,11 @@
{
"name": "@mizan/vue",
"version": "0.1.0",
"description": "Mizan Vue adapter — composables generated from @mizan/runtime.",
"type": "module",
"peerDependencies": {
"vue": ">=3",
"@mizan/runtime": ">=0.1.0"
},
"license": "MIT"
}