Flatten to three packages + extract mizan-runtime

packages/
  mizan-runtime/   Framework-agnostic state engine (~150 lines)
                   Context registry, batched invalidation, fetch primitives
  mizan-django/    Django server adapter (was packages/mizan-rpc/adapters/django/)
                   Codegen moved to mizan-django/generate/
  mizan-react/     React adapter (was packages/mizan-csr/adapters/react/)

Removed premature abstractions: mizan-ast, mizan-schema, mizan-rpc,
mizan-csr, mizan-ssr stub packages. The actual architecture is three
concrete packages, not five abstract layers.

mizan-runtime implements the v1 spec: registerContext with params,
scoped invalidation via microtask batching, server-driven invalidation
from mutation responses, mizanFetch for context bundles, mizanCall for
mutations.

264 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 15:47:17 -04:00
parent b28ee72c67
commit 787f90fd12
141 changed files with 167 additions and 15 deletions

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<TDjangoMessage> {
/** Called when subscribed successfully */
onSubscribed?: () => void
/** Called when a message is received */
onMessage?: (message: TDjangoMessage) => 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,
TDjangoMessage = unknown,
TReactMessage = unknown,
>(
channelName: string,
params?: TParams,
options: UseChannelOptions<TDjangoMessage> = {},
): ChannelSubscription<TParams, TDjangoMessage, TReactMessage> {
const { connection, status: connectionStatus } = useChannelContext()
const [messages, setMessages] = useState<TDjangoMessage[]>([])
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 TDjangoMessage
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, TDjangoMessage, TReactMessage>['send'],
unsubscribe,
clearMessages,
}
}
/**
* Get only the latest message from a channel (useful for presence, typing indicators)
*/
export function useChannelLatest<
TParams = undefined,
TDjangoMessage = unknown,
TReactMessage = unknown,
>(
channelName: string,
params?: TParams,
options: UseChannelOptions<TDjangoMessage> = {},
): Omit<ChannelSubscription<TParams, TDjangoMessage, TReactMessage>, 'messages'> & { latest: TDjangoMessage | null } {
const [latest, setLatest] = useState<TDjangoMessage | null>(null)
const channel = useChannel<TParams, TDjangoMessage, 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, TDjangoMessage = unknown, TReactMessage = unknown> {
/** Current connection status */
status: ConnectionStatus
/** Received messages */
messages: TDjangoMessage[]
/** 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