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:
165
packages/mizan-react/src/channels/__tests__/connection.test.ts
Normal file
165
packages/mizan-react/src/channels/__tests__/connection.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Tests for ChannelConnection
|
||||
*
|
||||
* These tests verify the ChannelConnection class API.
|
||||
* Unit tests for class structure don't require a real backend.
|
||||
* Integration tests for actual WebSocket connections require the backend.
|
||||
*
|
||||
* Backend must be running for integration tests: docker-compose up
|
||||
*/
|
||||
|
||||
import { ChannelConnection, RPCError } from '../connection'
|
||||
import { describeIntegration, WS_URL } from '../../testing'
|
||||
|
||||
describe('ChannelConnection (unit tests)', () => {
|
||||
describe('construction', () => {
|
||||
it('should start in disconnected state', () => {
|
||||
const connection = new ChannelConnection({ url: 'ws://localhost/ws/' })
|
||||
expect(connection.status).toBe('disconnected')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('status change handlers', () => {
|
||||
it('should allow subscribing to status changes', () => {
|
||||
const connection = new ChannelConnection({ url: 'ws://localhost/ws/' })
|
||||
const handler = jest.fn()
|
||||
|
||||
const unsubscribe = connection.onStatusChange(handler)
|
||||
|
||||
expect(typeof unsubscribe).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('message handlers', () => {
|
||||
it('should allow subscribing to messages', () => {
|
||||
const connection = new ChannelConnection({ url: 'ws://localhost/ws/' })
|
||||
const handler = jest.fn()
|
||||
|
||||
const unsubscribe = connection.onMessage(handler)
|
||||
|
||||
expect(typeof unsubscribe).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('send queueing', () => {
|
||||
it('should queue messages when not connected', () => {
|
||||
const connection = new ChannelConnection({
|
||||
url: 'ws://localhost/ws/',
|
||||
reconnect: false,
|
||||
})
|
||||
|
||||
// This shouldn't throw
|
||||
connection.send({
|
||||
action: 'subscribe',
|
||||
channel: 'test',
|
||||
params: {},
|
||||
})
|
||||
|
||||
// Status should still be disconnected (or connecting if it auto-connected)
|
||||
expect(['disconnected', 'connecting']).toContain(connection.status)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rpc', () => {
|
||||
it('should queue rpc messages when not connected', () => {
|
||||
const connection = new ChannelConnection({
|
||||
url: 'ws://localhost/ws/',
|
||||
reconnect: false,
|
||||
})
|
||||
|
||||
const promise = connection.rpc('test_fn', { arg: 'value' })
|
||||
|
||||
expect(promise).toBeInstanceOf(Promise)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describeIntegration('ChannelConnection (integration)', () => {
|
||||
describe('real WebSocket connection', () => {
|
||||
it('should connect to real backend WebSocket', async () => {
|
||||
const connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
|
||||
const statusChanges: string[] = []
|
||||
connection.onStatusChange((status) => {
|
||||
statusChanges.push(status)
|
||||
})
|
||||
|
||||
// Connect
|
||||
connection.connect()
|
||||
|
||||
// Wait for connection
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Connection timeout'))
|
||||
}, 5000)
|
||||
|
||||
const unsubscribe = connection.onStatusChange((status) => {
|
||||
if (status === 'connected') {
|
||||
clearTimeout(timeout)
|
||||
unsubscribe()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(connection.status).toBe('connected')
|
||||
|
||||
// Cleanup
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
it('should disconnect cleanly', async () => {
|
||||
const connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
|
||||
// Connect first
|
||||
connection.connect()
|
||||
await new Promise<void>((resolve) => {
|
||||
const unsubscribe = connection.onStatusChange((status) => {
|
||||
if (status === 'connected') {
|
||||
unsubscribe()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Now disconnect
|
||||
connection.disconnect()
|
||||
|
||||
// Should be disconnected
|
||||
expect(connection.status).toBe('disconnected')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('RPCError', () => {
|
||||
it('should be an Error subclass', () => {
|
||||
const error = new RPCError('TEST_CODE', 'Test message')
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error).toBeInstanceOf(RPCError)
|
||||
})
|
||||
|
||||
it('should have correct properties', () => {
|
||||
const error = new RPCError('VALIDATION_ERROR', 'Field is required', { field: 'email' })
|
||||
|
||||
expect(error.code).toBe('VALIDATION_ERROR')
|
||||
expect(error.message).toBe('Field is required')
|
||||
expect(error.details).toEqual({ field: 'email' })
|
||||
expect(error.name).toBe('RPCError')
|
||||
})
|
||||
|
||||
it('should work without details', () => {
|
||||
const error = new RPCError('NOT_FOUND', 'Function not found')
|
||||
|
||||
expect(error.code).toBe('NOT_FOUND')
|
||||
expect(error.message).toBe('Function not found')
|
||||
expect(error.details).toBeUndefined()
|
||||
})
|
||||
})
|
||||
207
packages/mizan-react/src/channels/__tests__/context.test.tsx
Normal file
207
packages/mizan-react/src/channels/__tests__/context.test.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Tests for ChannelProvider context
|
||||
*
|
||||
* Unit tests run without backend.
|
||||
* Integration tests require: docker-compose up
|
||||
*
|
||||
* Run integration tests with: RUN_INTEGRATION_TESTS=true npm run test
|
||||
*/
|
||||
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { ReactNode } from 'react'
|
||||
import { ChannelProvider, useChannelContext, useChannelStatus } from '../context'
|
||||
import { ChannelConnection } from '../connection'
|
||||
import { describeIntegration, WS_URL } from '../../testing'
|
||||
|
||||
// ============================================================================
|
||||
// Unit Tests (no backend required)
|
||||
// ============================================================================
|
||||
|
||||
describe('ChannelProvider (unit)', () => {
|
||||
describe('useChannelContext', () => {
|
||||
it('should throw when used outside ChannelProvider', () => {
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useChannelContext())
|
||||
}).toThrow('useChannelContext must be used within a ChannelProvider')
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should return connection and status when inside provider', () => {
|
||||
const connection = new ChannelConnection({
|
||||
url: 'ws://localhost/ws/',
|
||||
reconnect: false,
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ChannelProvider connection={connection} autoConnect={false}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useChannelContext(), { wrapper })
|
||||
|
||||
expect(result.current.connection).toBe(connection)
|
||||
expect(result.current.status).toBe('disconnected')
|
||||
|
||||
connection.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useChannelStatus', () => {
|
||||
it('should return disconnected when autoConnect is false', () => {
|
||||
const connection = new ChannelConnection({
|
||||
url: 'ws://localhost/ws/',
|
||||
reconnect: false,
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ChannelProvider connection={connection} autoConnect={false}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useChannelStatus(), { wrapper })
|
||||
expect(result.current).toBe('disconnected')
|
||||
|
||||
connection.disconnect()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Integration Tests (require running backend)
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('ChannelProvider (integration)', () => {
|
||||
describe('with real WebSocket connection', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
const createWrapper = (autoConnect = true) => {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ChannelProvider
|
||||
connection={connection}
|
||||
autoConnect={autoConnect}
|
||||
>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it('should auto-connect when autoConnect is true', async () => {
|
||||
const { result } = renderHook(() => useChannelContext(), {
|
||||
wrapper: createWrapper(true),
|
||||
})
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('should not auto-connect when autoConnect is false', () => {
|
||||
const { result } = renderHook(() => useChannelContext(), {
|
||||
wrapper: createWrapper(false),
|
||||
})
|
||||
|
||||
expect(result.current.status).toBe('disconnected')
|
||||
})
|
||||
|
||||
it('should update status when connection status changes', async () => {
|
||||
const { result } = renderHook(() => useChannelStatus(), {
|
||||
wrapper: createWrapper(true),
|
||||
})
|
||||
|
||||
// Should start connecting then become connected
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('should disconnect on unmount', async () => {
|
||||
const { result, unmount } = renderHook(() => useChannelContext(), {
|
||||
wrapper: createWrapper(true),
|
||||
})
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Unmount
|
||||
unmount()
|
||||
|
||||
// Connection should be disconnected
|
||||
expect(connection.status).toBe('disconnected')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describeIntegration('useChannelStatus (integration)', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
const createWrapper = (autoConnect: boolean) => {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ChannelProvider connection={connection} autoConnect={autoConnect}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it('should return current connection status', () => {
|
||||
const { result } = renderHook(() => useChannelStatus(), {
|
||||
wrapper: createWrapper(false),
|
||||
})
|
||||
|
||||
expect(result.current).toBe('disconnected')
|
||||
})
|
||||
|
||||
it('should track status through connection lifecycle', async () => {
|
||||
const { result } = renderHook(() => useChannelStatus(), {
|
||||
wrapper: createWrapper(true),
|
||||
})
|
||||
|
||||
// Wait for connected
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Disconnect manually
|
||||
act(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
// Should become disconnected
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('disconnected')
|
||||
})
|
||||
})
|
||||
})
|
||||
158
packages/mizan-react/src/channels/__tests__/hooks.test.tsx
Normal file
158
packages/mizan-react/src/channels/__tests__/hooks.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Integration tests for channel hooks
|
||||
*
|
||||
* These tests call the REAL backend - no mocks.
|
||||
* Backend must be running: docker-compose up
|
||||
*
|
||||
* Run with: RUN_INTEGRATION_TESTS=true npm run test
|
||||
*/
|
||||
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { ReactNode } from 'react'
|
||||
import { ChannelProvider } from '../context'
|
||||
import { useChannel, useChannelLatest, useRPC } from '../hooks'
|
||||
import { ChannelConnection } from '../connection'
|
||||
import { describeIntegration, WS_URL } from '../../testing'
|
||||
|
||||
describeIntegration('useChannel (integration)', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ChannelProvider connection={connection} autoConnect={true}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
describe('subscription', () => {
|
||||
it('should subscribe to channel when connection is ready', async () => {
|
||||
const { result } = renderHook(
|
||||
() => useChannel<{ room: string }, { text: string }, { text: string }>('chat', { room: 'test' }),
|
||||
{ wrapper: createWrapper() }
|
||||
)
|
||||
|
||||
// Wait for connection to establish
|
||||
await waitFor(() => {
|
||||
// Status should progress from connecting
|
||||
expect(['connecting', 'connected', 'subscribed']).toContain(result.current.status)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Should have expected API
|
||||
expect(typeof result.current.send).toBe('function')
|
||||
expect(typeof result.current.clearMessages).toBe('function')
|
||||
expect(typeof result.current.unsubscribe).toBe('function')
|
||||
expect(Array.isArray(result.current.messages)).toBe(true)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
describeIntegration('useChannelLatest (integration)', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ChannelProvider connection={connection} autoConnect={true}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
describeIntegration('useRPC (integration)', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ChannelProvider connection={connection} autoConnect={true}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it('should track connection status', async () => {
|
||||
const { result } = renderHook(() => useRPC(), { wrapper: createWrapper() })
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('should call backend echo function via RPC', async () => {
|
||||
const { result } = renderHook(() => useRPC(), { wrapper: createWrapper() })
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Call echo function
|
||||
const response = await result.current.call<{ text: string }, { message: string }>(
|
||||
'echo',
|
||||
{ text: 'rpc test' }
|
||||
)
|
||||
|
||||
expect(response).toHaveProperty('message')
|
||||
expect(response.message).toContain('rpc test')
|
||||
})
|
||||
|
||||
it('should call backend add function via RPC', async () => {
|
||||
const { result } = renderHook(() => useRPC(), { wrapper: createWrapper() })
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Call add function
|
||||
const response = await result.current.call<{ a: number; b: number }, { result: number }>(
|
||||
'add',
|
||||
{ a: 7, b: 8 }
|
||||
)
|
||||
|
||||
expect(response).toHaveProperty('result', 15)
|
||||
})
|
||||
})
|
||||
|
||||
299
packages/mizan-react/src/channels/connection.ts
Normal file
299
packages/mizan-react/src/channels/connection.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* WebSocket connection manager for mizan/channels
|
||||
*
|
||||
* Supports both pub/sub channels AND RPC calls over the same connection.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
OutgoingMessage,
|
||||
IncomingPayload,
|
||||
SubscribeOptions,
|
||||
} from './types'
|
||||
|
||||
type MessageHandler = (payload: IncomingPayload) => void
|
||||
type StatusHandler = (status: ConnectionStatus) => void
|
||||
|
||||
/** RPC request message */
|
||||
export interface RPCRequest<T = unknown> {
|
||||
action: 'rpc'
|
||||
id: string
|
||||
fn: string
|
||||
args: T
|
||||
}
|
||||
|
||||
/** RPC response - success */
|
||||
export interface RPCSuccessResponse<T = unknown> {
|
||||
id: string
|
||||
ok: true
|
||||
data: T
|
||||
}
|
||||
|
||||
/** RPC response - error */
|
||||
export interface RPCErrorResponse {
|
||||
id: string
|
||||
ok: false
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export type RPCResponse<T = unknown> = RPCSuccessResponse<T> | RPCErrorResponse
|
||||
|
||||
/** RPC error thrown on failure */
|
||||
export class RPCError extends Error {
|
||||
code: string
|
||||
details?: Record<string, unknown>
|
||||
|
||||
constructor(code: string, message: string, details?: Record<string, unknown>) {
|
||||
super(message)
|
||||
this.name = 'RPCError'
|
||||
this.code = code
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChannelConnectionOptions {
|
||||
/** WebSocket URL (default: /ws/) */
|
||||
url?: string
|
||||
|
||||
/** Reconnect on disconnect (default: true) */
|
||||
reconnect?: boolean
|
||||
|
||||
/** Reconnection delay in ms (default: 1000) */
|
||||
reconnectDelay?: number
|
||||
|
||||
/** Maximum reconnection attempts (default: 10) */
|
||||
maxReconnectAttempts?: number
|
||||
}
|
||||
|
||||
export class ChannelConnection {
|
||||
private ws: WebSocket | null = null
|
||||
private url: string
|
||||
private reconnect: boolean
|
||||
private reconnectDelay: number
|
||||
private maxReconnectAttempts: number
|
||||
private reconnectAttempts = 0
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
private messageHandlers: Set<MessageHandler> = new Set()
|
||||
private statusHandlers: Set<StatusHandler> = new Set()
|
||||
|
||||
private _status: ConnectionStatus = 'disconnected'
|
||||
private pendingMessages: OutgoingMessage[] = []
|
||||
|
||||
// RPC state
|
||||
private rpcIdCounter = 0
|
||||
private pendingRPCs: Map<string, {
|
||||
resolve: (data: unknown) => void
|
||||
reject: (error: RPCError) => void
|
||||
}> = new Map()
|
||||
|
||||
constructor(options: ChannelConnectionOptions = {}) {
|
||||
// Build WebSocket URL
|
||||
const baseUrl = options.url || '/ws/'
|
||||
if (typeof window !== 'undefined') {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
this.url = baseUrl.startsWith('ws')
|
||||
? baseUrl
|
||||
: `${protocol}//${window.location.host}${baseUrl}`
|
||||
} else {
|
||||
this.url = baseUrl
|
||||
}
|
||||
|
||||
this.reconnect = options.reconnect ?? true
|
||||
this.reconnectDelay = options.reconnectDelay ?? 1000
|
||||
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10
|
||||
}
|
||||
|
||||
get status(): ConnectionStatus {
|
||||
return this._status
|
||||
}
|
||||
|
||||
private setStatus(status: ConnectionStatus) {
|
||||
this._status = status
|
||||
this.statusHandlers.forEach(handler => handler(status))
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setStatus('connecting')
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0
|
||||
this.setStatus('connected')
|
||||
|
||||
// Send any pending messages
|
||||
this.pendingMessages.forEach(msg => this.send(msg))
|
||||
this.pendingMessages = []
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
this.setStatus('disconnected')
|
||||
|
||||
// Attempt reconnection if enabled
|
||||
if (this.reconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// WebSocket errors don't provide useful details (browser security)
|
||||
// The onclose handler will fire next and trigger reconnection
|
||||
console.warn('[ChannelConnection] WebSocket error (will reconnect)')
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
|
||||
// Check if this is an RPC response (has 'id' and 'ok' fields)
|
||||
if ('id' in payload && 'ok' in payload) {
|
||||
this.handleRPCResponse(payload as RPCResponse)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, it's a channel message
|
||||
this.messageHandlers.forEach(handler => handler(payload as IncomingPayload))
|
||||
} catch (e) {
|
||||
console.error('[ChannelConnection] Failed to parse message:', e)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ChannelConnection] Failed to connect:', error)
|
||||
this.setStatus('disconnected')
|
||||
|
||||
if (this.reconnect) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.reconnect = false
|
||||
this.clearReconnectTimer()
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
|
||||
this.setStatus('disconnected')
|
||||
}
|
||||
|
||||
send(message: OutgoingMessage): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message))
|
||||
} else {
|
||||
// Queue message to send when connected
|
||||
this.pendingMessages.push(message)
|
||||
|
||||
// Ensure we're trying to connect
|
||||
if (this._status === 'disconnected') {
|
||||
this.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMessage(handler: MessageHandler): () => void {
|
||||
this.messageHandlers.add(handler)
|
||||
return () => this.messageHandlers.delete(handler)
|
||||
}
|
||||
|
||||
onStatusChange(handler: StatusHandler): () => void {
|
||||
this.statusHandlers.add(handler)
|
||||
return () => this.statusHandlers.delete(handler)
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.clearReconnectTimer()
|
||||
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts)
|
||||
this.reconnectAttempts++
|
||||
|
||||
console.log(`[ChannelConnection] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// RPC Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Call a server function via RPC.
|
||||
*
|
||||
* @param fn - Function name (as registered on backend)
|
||||
* @param args - Function arguments
|
||||
* @returns Promise resolving to function output
|
||||
* @throws RPCError on failure
|
||||
*/
|
||||
rpc<TInput, TOutput>(fn: string, args: TInput): Promise<TOutput> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = `rpc_${++this.rpcIdCounter}_${Date.now()}`
|
||||
|
||||
// Store pending RPC
|
||||
this.pendingRPCs.set(id, {
|
||||
resolve: resolve as (data: unknown) => void,
|
||||
reject,
|
||||
})
|
||||
|
||||
// Send RPC request
|
||||
const request: RPCRequest<TInput> = {
|
||||
action: 'rpc',
|
||||
id,
|
||||
fn,
|
||||
args,
|
||||
}
|
||||
|
||||
this.send(request as unknown as OutgoingMessage)
|
||||
})
|
||||
}
|
||||
|
||||
private handleRPCResponse(response: RPCResponse): void {
|
||||
const pending = this.pendingRPCs.get(response.id)
|
||||
if (!pending) {
|
||||
console.warn(`[ChannelConnection] Received RPC response for unknown id: ${response.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingRPCs.delete(response.id)
|
||||
|
||||
if (response.ok) {
|
||||
pending.resolve(response.data)
|
||||
} else {
|
||||
pending.reject(new RPCError(
|
||||
response.error.code,
|
||||
response.error.message,
|
||||
response.error.details,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton connection instance
|
||||
let defaultConnection: ChannelConnection | null = null
|
||||
|
||||
export function getDefaultConnection(options?: ChannelConnectionOptions): ChannelConnection {
|
||||
if (!defaultConnection) {
|
||||
defaultConnection = new ChannelConnection(options)
|
||||
}
|
||||
return defaultConnection
|
||||
}
|
||||
102
packages/mizan-react/src/channels/context.tsx
Normal file
102
packages/mizan-react/src/channels/context.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* React context for mizan/channels
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { ChannelConnection, type ChannelConnectionOptions } from './connection'
|
||||
import type { ConnectionStatus } from './types'
|
||||
|
||||
interface ChannelContextValue {
|
||||
connection: ChannelConnection
|
||||
status: ConnectionStatus
|
||||
}
|
||||
|
||||
const ChannelContext = createContext<ChannelContextValue | null>(null)
|
||||
|
||||
export interface ChannelProviderProps {
|
||||
children: ReactNode
|
||||
|
||||
/** WebSocket URL (default: /ws/) */
|
||||
url?: string
|
||||
|
||||
/** Reconnect on disconnect (default: true) */
|
||||
reconnect?: boolean
|
||||
|
||||
/** Reconnection delay in ms (default: 1000) */
|
||||
reconnectDelay?: number
|
||||
|
||||
/** Maximum reconnection attempts (default: 10) */
|
||||
maxReconnectAttempts?: number
|
||||
|
||||
/** Connect automatically on mount (default: true) */
|
||||
autoConnect?: boolean
|
||||
|
||||
/** Custom connection instance (for testing) */
|
||||
connection?: ChannelConnection
|
||||
}
|
||||
|
||||
export function ChannelProvider({
|
||||
children,
|
||||
url,
|
||||
reconnect,
|
||||
reconnectDelay,
|
||||
maxReconnectAttempts,
|
||||
autoConnect = true,
|
||||
connection: providedConnection,
|
||||
}: ChannelProviderProps) {
|
||||
const connectionRef = useRef<ChannelConnection | null>(null)
|
||||
|
||||
// Use provided connection or create one
|
||||
if (!connectionRef.current) {
|
||||
connectionRef.current = providedConnection ?? new ChannelConnection({
|
||||
url,
|
||||
reconnect,
|
||||
reconnectDelay,
|
||||
maxReconnectAttempts,
|
||||
})
|
||||
}
|
||||
|
||||
const connection = connectionRef.current
|
||||
|
||||
// Track status for context value
|
||||
const [status, setStatus] = useState<ConnectionStatus>(connection.status)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = connection.onStatusChange(setStatus)
|
||||
|
||||
if (autoConnect) {
|
||||
connection.connect()
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
connection.disconnect()
|
||||
}
|
||||
}, [connection, autoConnect])
|
||||
|
||||
const value = useMemo(() => ({
|
||||
connection,
|
||||
status,
|
||||
}), [connection, status])
|
||||
|
||||
return (
|
||||
<ChannelContext value={value}>
|
||||
{children}
|
||||
</ChannelContext>
|
||||
)
|
||||
}
|
||||
|
||||
export function useChannelContext(): ChannelContextValue {
|
||||
const context = useContext(ChannelContext)
|
||||
if (!context) {
|
||||
throw new Error('useChannelContext must be used within a ChannelProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function useChannelStatus(): ConnectionStatus {
|
||||
const { status } = useChannelContext()
|
||||
return status
|
||||
}
|
||||
256
packages/mizan-react/src/channels/hooks.ts
Normal file
256
packages/mizan-react/src/channels/hooks.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* React hooks for mizan/channels
|
||||
*
|
||||
* Includes pub/sub channel hooks AND RPC hooks.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useChannelContext } from './context'
|
||||
import { RPCError } from './connection'
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
ChannelSubscription,
|
||||
IncomingPayload,
|
||||
} from './types'
|
||||
|
||||
export interface UseChannelOptions<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 }
|
||||
76
packages/mizan-react/src/channels/index.ts
Normal file
76
packages/mizan-react/src/channels/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* mizan/channels
|
||||
*
|
||||
* Real-time WebSocket communication with Django Channels.
|
||||
* Type-safe bidirectional messaging.
|
||||
*
|
||||
* ## Setup
|
||||
*
|
||||
* ```tsx
|
||||
* // layout.tsx
|
||||
* import { ChannelProvider } from 'mizan/channels'
|
||||
*
|
||||
* export default function Layout({ children }) {
|
||||
* return (
|
||||
* <ChannelProvider>
|
||||
* {children}
|
||||
* </ChannelProvider>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* ```tsx
|
||||
* // Using generated hooks (recommended)
|
||||
* import { useChatChannel } from '@/api/generated.channels'
|
||||
*
|
||||
* function Chat({ room }) {
|
||||
* const chat = useChatChannel({ room })
|
||||
*
|
||||
* chat.status // 'connecting' | 'connected' | 'disconnected'
|
||||
* chat.messages // DjangoMessage[]
|
||||
* chat.send({ text: 'Hello' }) // Send ReactMessage
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ```tsx
|
||||
* // Using raw hook (for custom channels)
|
||||
* import { useChannel } from 'mizan/channels'
|
||||
*
|
||||
* function CustomChannel() {
|
||||
* const channel = useChannel<
|
||||
* { room: string }, // Params
|
||||
* { user: string; text: string }, // DjangoMessage
|
||||
* { text: string } // ReactMessage
|
||||
* >('chat', { room: 'general' })
|
||||
*
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Context
|
||||
export { ChannelProvider, useChannelContext, useChannelStatus } from './context'
|
||||
export type { ChannelProviderProps } from './context'
|
||||
|
||||
// Hooks
|
||||
export { useChannel, useChannelLatest, useRPC, RPCError } from './hooks'
|
||||
export type { UseChannelOptions, RPCClient } from './hooks'
|
||||
|
||||
// Connection (for advanced use)
|
||||
export { ChannelConnection, getDefaultConnection } from './connection'
|
||||
export type {
|
||||
ChannelConnectionOptions,
|
||||
RPCRequest,
|
||||
RPCResponse,
|
||||
RPCSuccessResponse,
|
||||
RPCErrorResponse,
|
||||
} from './connection'
|
||||
|
||||
// Types
|
||||
export type {
|
||||
ConnectionStatus,
|
||||
ChannelSubscription,
|
||||
SubscribeOptions,
|
||||
} from './types'
|
||||
84
packages/mizan-react/src/channels/types.ts
Normal file
84
packages/mizan-react/src/channels/types.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Types for mizan/channels
|
||||
*/
|
||||
|
||||
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected'
|
||||
|
||||
export interface ChannelSubscription<TParams = unknown, 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
|
||||
Reference in New Issue
Block a user