A channel's message slots are named from the client, on every backend
The IR called them react-message and django-message, so a FastAPI channel had to declare a DjangoMessage. They are client-message and server-message now, and the direction words hold wherever a channel is declared: Params / ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and friends so no backend names a type itself. Django's ReactChannel and FastAPI's ReactChannel are both Channel. mizan-fastapi never registered a channels extension, so build_ir() emitted no channel at all and every payload type was invisible to codegen. It registers one now. RegistryExtension is an ABC requiring all(), which is what the IR reads — an extension that cannot enumerate its registrations no longer exists. The gate that should have caught the rename could not: tests/afi registered no channel because mizan-rust had no channel registry to register one in, so a five-package rename of the wire contract passed byte-parity without a channel byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a #[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's split; the AFI fixture now carries a channel with every slot and one with a single slot, so all three backends prove the contract byte for byte. MizanChannel held three Option<String> beside three has_*() predicates and unwrapped them with defaults; it holds an ordered slot vector, so an absent slot is absent rather than defaulted. The channels target emitted a React hooks file that a stage1-only consumer could not compile — react emits that now. The codegen's parity tests byte-compared emitted source against baselines without ever compiling it: they compile the generated crate and run its tests, import the generated Python package and call every method, and typecheck each TypeScript target against a consumer. Also fixed at source: app_visitor printed its import diagnostic to stdout, the stream export_mizan_ir writes KDL to, so a failed import silently corrupted the IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on any non-JSON-serializable field default; Django and mizan-core derived Pascal names two different ways, disagreeing on every dotted channel name. ir.py builds a document and renders templates/ir/document.kdl.j2 rather than appending KDL strings with hand-tracked indentation, and named types resolve to a fixed point — a model reachable only through a union branch was referenced by a ref that no type block ever defined. The rest is the write-gate's own classifiers run over the standing tree: relative imports, silent swallows, Protocol contracts that should be ABCs, emitters hand-rendering target source, catch-all arms over closed enums, and comments narrating the project rather than the code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,5 @@
|
||||
/**
|
||||
* @mizan/base — The client state kernel.
|
||||
*
|
||||
* Zero framework dependencies. React, Vue, Svelte — all import from here.
|
||||
*
|
||||
* The kernel owns the data. Adapters subscribe and render.
|
||||
* The client state kernel: context registry, merge, and invalidation.
|
||||
*/
|
||||
|
||||
// === Error ===
|
||||
@@ -24,7 +20,8 @@ export class MizanError extends Error {
|
||||
this.code = source?.code ?? `HTTP_${status}`
|
||||
this.details = source?.details
|
||||
if (source?.message) this.message = source.message
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.warn(`[mizan] Unparseable error envelope for HTTP ${status}:`, e)
|
||||
this.code = `HTTP_${status}`
|
||||
}
|
||||
}
|
||||
@@ -33,11 +30,8 @@ export class MizanError extends Error {
|
||||
// === Transport ===
|
||||
|
||||
/**
|
||||
* Wire surface the kernel uses to reach a Mizan backend. The default
|
||||
* implementation is `httpTransport()` (POST /call/, GET /ctx/). Tauri
|
||||
* apps swap in `tauriTransport()` from `@mizan/tauri-transport`. Any
|
||||
* future transport — workers, edge runtimes, channels — implements this
|
||||
* interface and replaces the default via `configure({ transport })`.
|
||||
* Wire surface the kernel uses to reach a Mizan backend. `configure({
|
||||
* transport })` swaps the default `httpTransport()` for another one.
|
||||
*/
|
||||
export interface MizanTransport {
|
||||
/** RPC dispatch — invokes a Mizan-registered function. */
|
||||
@@ -53,9 +47,8 @@ export interface MizanTransport {
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw envelope a transport returns from `call()`. The kernel uses the
|
||||
* `merge` and `invalidate` arrays to drive client-side cache updates;
|
||||
* `result` is the function's typed return value.
|
||||
* Raw envelope a transport returns from `call()`. `merge` and `invalidate`
|
||||
* drive client-side cache updates; `result` is the function's return value.
|
||||
*/
|
||||
export interface MizanCallResponse {
|
||||
result: any
|
||||
@@ -72,18 +65,11 @@ interface MizanConfig {
|
||||
csrfHeaderName: string
|
||||
/**
|
||||
* Whether the backend exposes `/session/` for CSRF/session bootstrap.
|
||||
* `true` for Django (the default — preserves existing setups); set
|
||||
* `false` for FastAPI or any backend that doesn't ship a session
|
||||
* endpoint to avoid a 404 storm on startup. A future revision moves
|
||||
* this onto the schema-advertised capability surface.
|
||||
* Django does; a backend without that endpoint answers 404 on every
|
||||
* startup unless this is `false`.
|
||||
*/
|
||||
session: boolean
|
||||
/**
|
||||
* Wire transport. Defaults to `httpTransport()` (fetch-based,
|
||||
* compatible with FastAPI / Django backends). Swap with a custom
|
||||
* transport (e.g. `tauriTransport()`) at app entry to route
|
||||
* Mizan calls through a different channel.
|
||||
*/
|
||||
/** Wire transport used for every `call()` and `fetch()`. */
|
||||
transport: MizanTransport
|
||||
}
|
||||
|
||||
@@ -93,7 +79,7 @@ const config: MizanConfig = {
|
||||
csrfCookieName: 'csrftoken',
|
||||
csrfHeaderName: 'X-CSRFToken',
|
||||
session: true,
|
||||
// Initialized below once httpTransport is defined.
|
||||
// Bound below, once httpTransport is in scope.
|
||||
transport: null as unknown as MizanTransport,
|
||||
}
|
||||
|
||||
@@ -167,10 +153,8 @@ function stableKey(params: Record<string, any>): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a context instance. The kernel owns the fetch lifecycle.
|
||||
*
|
||||
* Returns { getState, subscribe, refetch, unregister }.
|
||||
* Adapters call subscribe() to get notified on state changes.
|
||||
* Register a context instance keyed by (name, params). The returned handle
|
||||
* exposes the entry's state, a subscription, a refetch, and teardown.
|
||||
*/
|
||||
export function registerContext(
|
||||
name: string,
|
||||
@@ -187,7 +171,8 @@ export function registerContext(
|
||||
const key = stableKey(params)
|
||||
const map = contexts.get(name)!
|
||||
|
||||
// Reuse existing entry if same key is re-registered (React Strict Mode)
|
||||
// React Strict Mode re-registers the same key; reuse the entry so
|
||||
// subscribers already attached to it are not orphaned.
|
||||
let entry = map.get(key)
|
||||
if (!entry) {
|
||||
entry = {
|
||||
@@ -202,7 +187,6 @@ export function registerContext(
|
||||
}
|
||||
map.set(key, entry)
|
||||
} else {
|
||||
// Update fetchFn in case closure changed
|
||||
entry.fetchFn = fetchFn
|
||||
}
|
||||
|
||||
@@ -241,13 +225,9 @@ export function registerContext(
|
||||
|
||||
// === Merge ===
|
||||
//
|
||||
// A mutation that declares `@client(merge=ctx)` returns `{merge: [{context,
|
||||
// slot, params?, value}]}` alongside `result`/`invalidate`. The server has
|
||||
// already resolved which bundle slot the value lands in (by matching the
|
||||
// mutation's return type against each context function's return type), so
|
||||
// the kernel does no inference — it writes directly to `bundle[slot]`,
|
||||
// upserting by id when the slot is a list. The type information lives in
|
||||
// the schema-aware backend layer; the kernel is type-erased on purpose.
|
||||
// The server resolved which bundle slot the value lands in, so this writes
|
||||
// directly to `bundle[slot]` with no inference — upserting by id when the
|
||||
// slot holds a list.
|
||||
|
||||
function spliceSlot(slot: unknown, value: unknown): unknown {
|
||||
if (Array.isArray(slot)) {
|
||||
@@ -368,6 +348,7 @@ async function fetchWithRetry(
|
||||
if (attempt >= retries) return res
|
||||
} catch (e) {
|
||||
if (attempt >= retries) throw e
|
||||
console.warn(`[mizan] Fetch attempt ${attempt + 1} failed, retrying:`, e)
|
||||
}
|
||||
await new Promise(r => setTimeout(r, (attempt + 1) * 200))
|
||||
}
|
||||
@@ -387,11 +368,7 @@ async function resolveHeaders(): Promise<Record<string, string>> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default Mizan transport — POST `${baseUrl}/call/` and GET
|
||||
* `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`,
|
||||
* `mizan-django`, and `mizan-rust-axum`. Swap with a different
|
||||
* transport via `configure({ transport })` when running in a
|
||||
* non-HTTP host (e.g. Tauri).
|
||||
* Transport over POST `${baseUrl}/call/` and GET `${baseUrl}/ctx/${name}/`.
|
||||
*/
|
||||
export function httpTransport(): MizanTransport {
|
||||
return {
|
||||
@@ -431,9 +408,6 @@ export function httpTransport(): MizanTransport {
|
||||
}
|
||||
}
|
||||
|
||||
// Install the default transport now that httpTransport is in scope. The
|
||||
// config object was constructed earlier with a placeholder so the type
|
||||
// stayed honest; this line is the actual binding.
|
||||
config.transport = httpTransport()
|
||||
|
||||
export async function mizanFetch(
|
||||
@@ -449,16 +423,14 @@ export async function mizanCall(
|
||||
): Promise<any> {
|
||||
const data = await config.transport.call(functionName, args)
|
||||
|
||||
// Server-driven merges run before invalidations so a context that is
|
||||
// both merged-into and invalidated ends in the invalidation state — the
|
||||
// server told us to refetch, that wins.
|
||||
// Merges run before invalidations so a context that is both merged-into
|
||||
// and invalidated ends in the invalidation state.
|
||||
if (data.merge) {
|
||||
for (const entry of data.merge) {
|
||||
merge(entry.context, entry.params, entry.slot, entry.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Server-driven invalidation
|
||||
if (data.invalidate) {
|
||||
for (const entry of data.invalidate) {
|
||||
if (typeof entry === 'string') {
|
||||
@@ -466,9 +438,8 @@ export async function mizanCall(
|
||||
} else if ('context' in entry) {
|
||||
invalidate(entry.context, entry.params)
|
||||
}
|
||||
// {function: name} entries route through the kernel's
|
||||
// function-output cache layer, which lives in the framework
|
||||
// adapter; mizan-base treats them as a no-op here.
|
||||
// {function: name} entries are handled by the framework adapter's
|
||||
// function-output cache; no kernel-side state corresponds to them.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,15 @@
|
||||
/**
|
||||
* 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 { render, renderHook, screen, act } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import type { ReactNode } from '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', () => {
|
||||
@@ -35,7 +18,7 @@ describe('mizan Context (unit)', () => {
|
||||
return <div>Test</div>
|
||||
}
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
expect(() => render(<TestComponent />)).toThrow(
|
||||
'useMizan must be used within a MizanProvider'
|
||||
@@ -45,7 +28,7 @@ describe('mizan Context (unit)', () => {
|
||||
})
|
||||
|
||||
it('should return context value inside provider', () => {
|
||||
let contextValue: any = null
|
||||
let contextValue: ReturnType<typeof useMizan> | null = null
|
||||
|
||||
function TestComponent() {
|
||||
contextValue = useMizan()
|
||||
@@ -82,7 +65,7 @@ describe('mizan Context (unit)', () => {
|
||||
|
||||
describe('hydration', () => {
|
||||
it('should initialize context store from hydration data', () => {
|
||||
let contextValue: any = null
|
||||
let contextValue: ReturnType<typeof useMizan> | null = null
|
||||
|
||||
function TestComponent() {
|
||||
contextValue = useMizan()
|
||||
@@ -100,215 +83,105 @@ describe('mizan Context (unit)', () => {
|
||||
</MizanProvider>
|
||||
)
|
||||
|
||||
expect(contextValue.getContext('auth_status')).toEqual({ is_authenticated: false })
|
||||
expect(contextValue.getContext('user')).toEqual(null)
|
||||
expect(contextValue!.getContext('auth_status')).toEqual({ is_authenticated: false })
|
||||
expect(contextValue!.getContext('user')).toEqual(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Integration Tests (require running backend)
|
||||
// ============================================================================
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
{children}
|
||||
</MizanProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
const { result } = renderHook(() => useMizan().call, { wrapper: Wrapper })
|
||||
|
||||
function TestComponent() {
|
||||
const { call, status } = useMizan()
|
||||
let response: { message: string } | null = null
|
||||
await act(async () => {
|
||||
response = await result.current<{ text: string }, { message: string }>(
|
||||
'echo',
|
||||
{ text: 'context test' }
|
||||
)
|
||||
})
|
||||
|
||||
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')
|
||||
expect(response).toHaveProperty('message')
|
||||
expect(response!.message).toContain('context test')
|
||||
})
|
||||
|
||||
it('should call add function with correct result', async () => {
|
||||
let result: any = null
|
||||
let error: any = null
|
||||
const { result } = renderHook(() => useMizan().call, { wrapper: Wrapper })
|
||||
|
||||
function TestComponent() {
|
||||
const { call } = useMizan()
|
||||
let response: { result: number } | null = null
|
||||
await act(async () => {
|
||||
response = await result.current<{ a: number; b: number }, { result: number }>(
|
||||
'add',
|
||||
{ a: 10, b: 20 }
|
||||
)
|
||||
})
|
||||
|
||||
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 })
|
||||
expect(response).toEqual({ result: 30 })
|
||||
})
|
||||
|
||||
it('should throw MizanError for validation errors', async () => {
|
||||
let result: any = null
|
||||
let error: any = null
|
||||
const { result } = renderHook(() => useMizan().call, { wrapper: Wrapper })
|
||||
|
||||
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)
|
||||
// `echo` requires `text`; omitting it must reject rather than resolve.
|
||||
await expect(result.current('echo', {})).rejects.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>
|
||||
const { result } = renderHook(
|
||||
() => useMizanCall<EchoInput, EchoOutput>('echo'),
|
||||
{ wrapper: Wrapper }
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result || error).not.toBeNull()
|
||||
}, { timeout: 5000 })
|
||||
let response: EchoOutput | null = null
|
||||
await act(async () => {
|
||||
response = await result.current({ text: 'typed function test' })
|
||||
})
|
||||
|
||||
expect(error).toBeNull()
|
||||
expect(result).toHaveProperty('message')
|
||||
expect(result.message).toContain('typed function test')
|
||||
expect(response).toHaveProperty('message')
|
||||
expect(response!.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
|
||||
const { result } = renderHook(() => useMizan().call, { wrapper: Wrapper })
|
||||
|
||||
function TestComponent() {
|
||||
const { call } = useMizan()
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('login.schema', { data: {} })
|
||||
})
|
||||
|
||||
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')
|
||||
expect(response).toHaveProperty('fields')
|
||||
expect(response).toHaveProperty('meta')
|
||||
expect(response.fields).toHaveProperty('login')
|
||||
expect(response.fields).toHaveProperty('password')
|
||||
})
|
||||
|
||||
it('should call login.validate and get validation result', async () => {
|
||||
let result: any = null
|
||||
let error: any = null
|
||||
const { result } = renderHook(() => useMizan().call, { wrapper: Wrapper })
|
||||
|
||||
function TestComponent() {
|
||||
const { call } = useMizan()
|
||||
let response: any = null
|
||||
await act(async () => {
|
||||
response = await result.current('login.validate', {
|
||||
data: { login: 'test@example.com', password: 'testpass' },
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
expect(response).toHaveProperty('valid')
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Tests for Django Server Error
|
||||
*/
|
||||
|
||||
import { MizanError, type FunctionErrorResponse } from '../errors'
|
||||
|
||||
describe('MizanError', () => {
|
||||
|
||||
@@ -1,45 +1,26 @@
|
||||
/**
|
||||
* 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,
|
||||
useMizanFormCore,
|
||||
type FormCoreConfig,
|
||||
} from '../forms'
|
||||
import { DjangoContext } from '../context'
|
||||
import { MizanProvider } 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), {
|
||||
return renderHook(() => useMizanFormCore<TData>(config), {
|
||||
wrapper: ({ children }) => (
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
{children}
|
||||
</DjangoContext>
|
||||
</MizanProvider>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Integration Tests - Real Backend Calls
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('useDjangoFormCore (integration)', () => {
|
||||
describeIntegration('useMizanFormCore (integration)', () => {
|
||||
describe('Schema loading from real backend', () => {
|
||||
it('loads login form schema', async () => {
|
||||
const { result } = renderFormHook({
|
||||
@@ -54,7 +35,6 @@ describeIntegration('useDjangoFormCore (integration)', () => {
|
||||
|
||||
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')
|
||||
})
|
||||
@@ -70,7 +50,6 @@ describeIntegration('useDjangoFormCore (integration)', () => {
|
||||
|
||||
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')
|
||||
})
|
||||
@@ -155,7 +134,6 @@ describeIntegration('useDjangoFormCore (integration)', () => {
|
||||
})
|
||||
|
||||
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'),
|
||||
@@ -173,17 +151,14 @@ describeIntegration('useDjangoFormCore (integration)', () => {
|
||||
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')
|
||||
@@ -199,7 +174,6 @@ describeIntegration('useDjangoFormCore (integration)', () => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Set invalid value and touch
|
||||
act(() => {
|
||||
result.current.set('login', 'bad')
|
||||
})
|
||||
@@ -209,7 +183,6 @@ describeIntegration('useDjangoFormCore (integration)', () => {
|
||||
|
||||
expect(result.current.errors?.fields.login).toBeDefined()
|
||||
|
||||
// Set valid value and touch
|
||||
act(() => {
|
||||
result.current.set('login', 'valid@example.com')
|
||||
})
|
||||
@@ -232,7 +205,6 @@ describeIntegration('useDjangoFormCore (integration)', () => {
|
||||
|
||||
expect(result.current.hasErrors).toBe(false)
|
||||
|
||||
// Set invalid and touch
|
||||
act(() => {
|
||||
result.current.set('login', 'bad')
|
||||
})
|
||||
@@ -242,7 +214,6 @@ describeIntegration('useDjangoFormCore (integration)', () => {
|
||||
|
||||
expect(result.current.hasErrors).toBe(true)
|
||||
|
||||
// Set valid and touch
|
||||
act(() => {
|
||||
result.current.set('login', 'good@example.com')
|
||||
})
|
||||
@@ -264,21 +235,18 @@ describeIntegration('useDjangoFormCore (integration)', () => {
|
||||
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
|
||||
let submitResult: Awaited<ReturnType<typeof result.current.submit>> | undefined
|
||||
await act(async () => {
|
||||
submitResult = await result.current.submit()
|
||||
})
|
||||
|
||||
// Submit should return error (invalid credentials)
|
||||
// The exact error depends on backend behavior
|
||||
expect(submitResult).toBeDefined()
|
||||
expect(submitResult!.success).toBe(false)
|
||||
})
|
||||
|
||||
it('submits signup form with missing required fields', async () => {
|
||||
@@ -290,15 +258,13 @@ describeIntegration('useDjangoFormCore (integration)', () => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Submit with empty fields should return validation errors
|
||||
let submitResult: any
|
||||
let submitResult: Awaited<ReturnType<typeof result.current.submit>> | undefined
|
||||
await act(async () => {
|
||||
submitResult = await result.current.submit()
|
||||
})
|
||||
|
||||
// Should have validation errors for required fields
|
||||
expect(submitResult).toBeDefined()
|
||||
expect(submitResult.success).toBe(false)
|
||||
expect(submitResult!.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -349,14 +315,11 @@ describeIntegration('useDjangoFormCore (integration)', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
/**
|
||||
* 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'
|
||||
import { ChannelConnection, RPCError } from '../channels/connection'
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
@@ -23,13 +13,11 @@ function Wrapper({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }
|
||||
@@ -39,10 +27,6 @@ function waitForConnected(connection: ChannelConnection): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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 })
|
||||
@@ -116,10 +100,6 @@ describeIntegration('Executor framework validation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 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 })
|
||||
@@ -131,7 +111,6 @@ describeIntegration('Form framework validation', () => {
|
||||
|
||||
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]
|
||||
@@ -142,10 +121,7 @@ describeIntegration('Form framework validation', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -178,30 +154,13 @@ describeIntegration('Form framework validation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 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()
|
||||
}
|
||||
})
|
||||
await waitForConnected(connection)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -209,7 +168,6 @@ describeIntegration('WebSocket framework validation', () => {
|
||||
})
|
||||
|
||||
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) => {
|
||||
@@ -226,7 +184,6 @@ describeIntegration('WebSocket framework validation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// 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) => {
|
||||
@@ -238,7 +195,6 @@ describeIntegration('WebSocket framework validation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Send a message
|
||||
connection.send({
|
||||
action: 'message',
|
||||
channel: 'chat',
|
||||
@@ -309,10 +265,6 @@ describeIntegration('WebSocket framework validation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 4: HTTP happy path
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('HTTP happy path', () => {
|
||||
it('should call echo and receive echoed text', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
@@ -349,10 +301,6 @@ describeIntegration('HTTP happy path', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 5: Auth variations
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('Auth variations', () => {
|
||||
it('should reject staff_only for anonymous with UNAUTHORIZED', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
@@ -388,7 +336,7 @@ describeIntegration('Auth variations', () => {
|
||||
expect(error!.isAuthError()).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject verified_only for anonymous (callable auth)', async () => {
|
||||
it('should reject verified_only for anonymous with FORBIDDEN', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let error: MizanError | null = null
|
||||
@@ -400,17 +348,12 @@ describeIntegration('Auth variations', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// 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 })
|
||||
@@ -437,10 +380,6 @@ describeIntegration('Context functions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 7: Error code coverage
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('Error code coverage', () => {
|
||||
it('should return NOT_IMPLEMENTED for NotImplementedError', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
@@ -527,10 +466,6 @@ describeIntegration('Error code coverage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 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 })
|
||||
@@ -543,12 +478,10 @@ describeIntegration('mizanFormMixin integration', () => {
|
||||
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')
|
||||
@@ -580,7 +513,6 @@ describeIntegration('mizanFormMixin integration', () => {
|
||||
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')
|
||||
@@ -605,10 +537,6 @@ describeIntegration('mizanFormMixin integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 9: Formset integration
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('Formset integration', () => {
|
||||
it('should return formset schema for item form', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
@@ -632,7 +560,6 @@ describeIntegration('Formset integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Should have validation errors for the invalid form data
|
||||
expect(response).toHaveProperty('errors')
|
||||
})
|
||||
|
||||
@@ -650,10 +577,6 @@ describeIntegration('Formset integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 10: Channel authorization
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('Channel authorization', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
@@ -685,7 +608,6 @@ describeIntegration('Channel authorization', () => {
|
||||
})
|
||||
|
||||
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) => {
|
||||
@@ -702,7 +624,6 @@ describeIntegration('Channel authorization', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Now unsubscribe
|
||||
const unsubPromise = new Promise<any>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Unsubscribe timeout')), 5000)
|
||||
const unsub = connection.onMessage((msg) => {
|
||||
@@ -726,10 +647,6 @@ describeIntegration('Channel authorization', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 11: WebSocket RPC happy path
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('WebSocket RPC happy path', () => {
|
||||
let connection: ChannelConnection
|
||||
|
||||
@@ -762,13 +679,10 @@ describeIntegration('WebSocket RPC happy path', () => {
|
||||
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
|
||||
it('should reject multiply via RPC when it is not websocket-enabled', async () => {
|
||||
let rpcError: RPCError | null = null
|
||||
try {
|
||||
response = await connection.rpc<{ x: number; y: number }, { product: number }>(
|
||||
await connection.rpc<{ x: number; y: number }, { product: number }>(
|
||||
'multiply',
|
||||
{ x: 7, y: 6 }
|
||||
)
|
||||
@@ -776,12 +690,7 @@ describeIntegration('WebSocket RPC happy path', () => {
|
||||
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 })
|
||||
}
|
||||
expect(rpcError).toBeInstanceOf(RPCError)
|
||||
})
|
||||
|
||||
it('should reject ws_whoami via RPC when anonymous', async () => {
|
||||
@@ -797,15 +706,11 @@ describeIntegration('WebSocket RPC happy path', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
// A fresh address per run keeps the backend from rejecting a duplicate user.
|
||||
const uniqueEmail = `newuser+${Date.now()}@example.com`
|
||||
|
||||
let response: any = null
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { WebSocketServer } from 'ws'
|
||||
|
||||
interface IncomingFrame {
|
||||
action: string
|
||||
channel: string
|
||||
params?: Record<string, unknown>
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
export interface ChannelTestServer {
|
||||
url: string
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket server speaking the channel wire protocol: subscribe/unsubscribe are
|
||||
* acknowledged, and a `message` frame on a live subscription comes back down as a
|
||||
* channel message carrying the same payload.
|
||||
*/
|
||||
export async function startChannelServer(): Promise<ChannelTestServer> {
|
||||
const http = createServer()
|
||||
const wss = new WebSocketServer({ server: http, path: '/ws/' })
|
||||
|
||||
wss.on('connection', (socket) => {
|
||||
const subscriptions = new Set<string>()
|
||||
|
||||
socket.on('message', (raw) => {
|
||||
const frame = JSON.parse(String(raw)) as IncomingFrame
|
||||
const params = frame.params ?? {}
|
||||
const key = `${frame.channel}:${JSON.stringify(params)}`
|
||||
|
||||
if (frame.action === 'subscribe') {
|
||||
subscriptions.add(key)
|
||||
socket.send(JSON.stringify({
|
||||
subscribed: true,
|
||||
channel: frame.channel,
|
||||
params,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (frame.action === 'unsubscribe') {
|
||||
subscriptions.delete(key)
|
||||
socket.send(JSON.stringify({
|
||||
unsubscribed: true,
|
||||
channel: frame.channel,
|
||||
params,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (frame.action === 'message') {
|
||||
if (!subscriptions.has(key)) {
|
||||
socket.send(JSON.stringify({
|
||||
error: `Not subscribed to ${frame.channel}`,
|
||||
channel: frame.channel,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
channel: frame.channel,
|
||||
params,
|
||||
type: 'ServerMessage',
|
||||
data: frame.data,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify({ error: `Unknown action: ${frame.action}` }))
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
http.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
|
||||
const { port } = http.address() as AddressInfo
|
||||
|
||||
return {
|
||||
url: `ws://127.0.0.1:${port}/ws/`,
|
||||
// An upgraded socket stays counted by the http server until its FIN is
|
||||
// acknowledged, so closeAllConnections() is what lets close() call back.
|
||||
close: () => new Promise<void>((resolve, reject) => {
|
||||
wss.clients.forEach((client) => client.terminate())
|
||||
wss.close()
|
||||
http.closeAllConnections()
|
||||
http.close((httpError) => {
|
||||
if (httpError) {
|
||||
reject(httpError)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1,140 +1,130 @@
|
||||
/**
|
||||
* 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 { vi } from 'vitest'
|
||||
import { ChannelConnection, RPCError } from '../connection'
|
||||
import { describeIntegration, WS_URL } from '../../testing'
|
||||
import { startChannelServer, type ChannelTestServer } from './channelServer'
|
||||
|
||||
describe('ChannelConnection', () => {
|
||||
let server: ChannelTestServer
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await startChannelServer()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close()
|
||||
})
|
||||
|
||||
describe('ChannelConnection (unit tests)', () => {
|
||||
describe('construction', () => {
|
||||
it('should start in disconnected state', () => {
|
||||
const connection = new ChannelConnection({ url: 'ws://localhost/ws/' })
|
||||
it('should start disconnected until connect is called', () => {
|
||||
const connection = new ChannelConnection({ url: server.url, reconnect: false })
|
||||
|
||||
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()
|
||||
it('should report connecting then connected then disconnected', async () => {
|
||||
const connection = new ChannelConnection({ url: server.url, reconnect: false })
|
||||
const seen: string[] = []
|
||||
connection.onStatusChange((status) => seen.push(status))
|
||||
|
||||
connection.connect()
|
||||
await vi.waitFor(() => expect(connection.status).toBe('connected'), { timeout: 5000 })
|
||||
|
||||
connection.disconnect()
|
||||
|
||||
expect(seen.slice(0, 3)).toEqual(['connecting', 'connected', 'disconnected'])
|
||||
})
|
||||
|
||||
it('should stop delivering status after the returned unsubscribe runs', async () => {
|
||||
const connection = new ChannelConnection({ url: server.url, reconnect: false })
|
||||
const handler = vi.fn()
|
||||
const unsubscribe = connection.onStatusChange(handler)
|
||||
|
||||
expect(typeof unsubscribe).toBe('function')
|
||||
unsubscribe()
|
||||
connection.connect()
|
||||
await vi.waitFor(() => expect(connection.status).toBe('connected'), { timeout: 5000 })
|
||||
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
|
||||
connection.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
describe('message handlers', () => {
|
||||
it('should allow subscribing to messages', () => {
|
||||
const connection = new ChannelConnection({ url: 'ws://localhost/ws/' })
|
||||
const handler = jest.fn()
|
||||
it('should deliver server frames and stop after unsubscribe', async () => {
|
||||
const connection = new ChannelConnection({ url: server.url, reconnect: false })
|
||||
const received: unknown[] = []
|
||||
const unsubscribe = connection.onMessage((payload) => received.push(payload))
|
||||
|
||||
const unsubscribe = connection.onMessage(handler)
|
||||
connection.connect()
|
||||
await vi.waitFor(() => expect(connection.status).toBe('connected'), { timeout: 5000 })
|
||||
|
||||
expect(typeof unsubscribe).toBe('function')
|
||||
connection.send({ action: 'subscribe', channel: 'chat', params: { room: 'a' } })
|
||||
await vi.waitFor(() => {
|
||||
expect(received).toEqual([{ subscribed: true, channel: 'chat', params: { room: 'a' } }])
|
||||
}, { timeout: 5000 })
|
||||
|
||||
unsubscribe()
|
||||
const afterUnsubscribe: unknown[] = []
|
||||
connection.onMessage((payload) => afterUnsubscribe.push(payload))
|
||||
|
||||
connection.send({ action: 'unsubscribe', channel: 'chat', params: { room: 'a' } })
|
||||
await vi.waitFor(() => expect(afterUnsubscribe).toHaveLength(1), { timeout: 5000 })
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
|
||||
connection.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
describe('send queueing', () => {
|
||||
it('should queue messages when not connected', () => {
|
||||
const connection = new ChannelConnection({
|
||||
url: 'ws://localhost/ws/',
|
||||
reconnect: false,
|
||||
})
|
||||
it('should open the socket and flush frames sent while disconnected', async () => {
|
||||
const connection = new ChannelConnection({ url: server.url, reconnect: false })
|
||||
const received: unknown[] = []
|
||||
connection.onMessage((payload) => received.push(payload))
|
||||
|
||||
// This shouldn't throw
|
||||
connection.send({
|
||||
action: 'subscribe',
|
||||
channel: 'test',
|
||||
params: {},
|
||||
})
|
||||
connection.send({ action: 'subscribe', channel: 'queued', params: {} })
|
||||
|
||||
// Status should still be disconnected (or connecting if it auto-connected)
|
||||
expect(['disconnected', 'connecting']).toContain(connection.status)
|
||||
expect(connection.status).toBe('connecting')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(received).toEqual([{ subscribed: true, channel: 'queued', params: {} }])
|
||||
}, { timeout: 5000 })
|
||||
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
it('should reject a message frame for a channel that was never subscribed', async () => {
|
||||
const connection = new ChannelConnection({ url: server.url, reconnect: false })
|
||||
const received: unknown[] = []
|
||||
connection.onMessage((payload) => received.push(payload))
|
||||
|
||||
connection.send({ action: 'message', channel: 'ghost', params: {}, data: { text: 'hi' } })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(received).toEqual([{ error: 'Not subscribed to ghost', channel: 'ghost' }])
|
||||
}, { timeout: 5000 })
|
||||
|
||||
connection.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rpc', () => {
|
||||
it('should queue rpc messages when not connected', () => {
|
||||
const connection = new ChannelConnection({
|
||||
url: 'ws://localhost/ws/',
|
||||
reconnect: false,
|
||||
})
|
||||
it('should flush an rpc request queued before the socket opened', async () => {
|
||||
const connection = new ChannelConnection({ url: server.url, reconnect: false })
|
||||
const received: unknown[] = []
|
||||
connection.onMessage((payload) => received.push(payload))
|
||||
|
||||
const promise = connection.rpc('test_fn', { arg: 'value' })
|
||||
const pending = connection.rpc('test_fn', { arg: 'value' })
|
||||
|
||||
expect(promise).toBeInstanceOf(Promise)
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(pending).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,
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(received).toEqual([{ error: 'Unknown action: rpc' }])
|
||||
}, { timeout: 5000 })
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -146,7 +136,7 @@ describe('RPCError', () => {
|
||||
expect(error).toBeInstanceOf(RPCError)
|
||||
})
|
||||
|
||||
it('should have correct properties', () => {
|
||||
it('should carry code, message and details', () => {
|
||||
const error = new RPCError('VALIDATION_ERROR', 'Field is required', { field: 'email' })
|
||||
|
||||
expect(error.code).toBe('VALIDATION_ERROR')
|
||||
@@ -155,7 +145,7 @@ describe('RPCError', () => {
|
||||
expect(error.name).toBe('RPCError')
|
||||
})
|
||||
|
||||
it('should work without details', () => {
|
||||
it('should leave details undefined when none are given', () => {
|
||||
const error = new RPCError('NOT_FOUND', 'Function not found')
|
||||
|
||||
expect(error.code).toBe('NOT_FOUND')
|
||||
|
||||
@@ -1,205 +1,134 @@
|
||||
/**
|
||||
* 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 { vi } from 'vitest'
|
||||
import { ReactNode } from 'react'
|
||||
import { ChannelProvider, useChannelContext, useChannelStatus } from '../context'
|
||||
import { ChannelConnection } from '../connection'
|
||||
import { describeIntegration, WS_URL } from '../../testing'
|
||||
import { startChannelServer, type ChannelTestServer } from './channelServer'
|
||||
|
||||
// ============================================================================
|
||||
// Unit Tests (no backend required)
|
||||
// ============================================================================
|
||||
function wrapperFor(connection: ChannelConnection, autoConnect: boolean) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ChannelProvider connection={connection} autoConnect={autoConnect}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)', () => {
|
||||
describe('ChannelProvider', () => {
|
||||
let server: ChannelTestServer
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await startChannelServer()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
reconnect: false,
|
||||
})
|
||||
connection = new ChannelConnection({ url: server.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 throw when useChannelContext is called outside a provider', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
it('should return current connection status', () => {
|
||||
expect(() => {
|
||||
renderHook(() => useChannelContext())
|
||||
}).toThrow('useChannelContext must be used within a ChannelProvider')
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should expose the provided connection and its current status', () => {
|
||||
const { result } = renderHook(() => useChannelContext(), {
|
||||
wrapper: wrapperFor(connection, false),
|
||||
})
|
||||
|
||||
expect(result.current.connection).toBe(connection)
|
||||
expect(result.current.status).toBe('disconnected')
|
||||
})
|
||||
|
||||
it('should reach connected when autoConnect is true', async () => {
|
||||
const { result } = renderHook(() => useChannelContext(), {
|
||||
wrapper: wrapperFor(connection, true),
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('should leave the socket closed when autoConnect is false', () => {
|
||||
const { result } = renderHook(() => useChannelContext(), {
|
||||
wrapper: wrapperFor(connection, false),
|
||||
})
|
||||
|
||||
expect(result.current.status).toBe('disconnected')
|
||||
expect(connection.status).toBe('disconnected')
|
||||
})
|
||||
|
||||
it('should disconnect the connection on unmount', async () => {
|
||||
const { result, unmount } = renderHook(() => useChannelContext(), {
|
||||
wrapper: wrapperFor(connection, true),
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
unmount()
|
||||
|
||||
expect(connection.status).toBe('disconnected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useChannelStatus', () => {
|
||||
let server: ChannelTestServer
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await startChannelServer()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new ChannelConnection({ url: server.url, reconnect: false })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
it('should report disconnected without autoConnect', () => {
|
||||
const { result } = renderHook(() => useChannelStatus(), {
|
||||
wrapper: createWrapper(false),
|
||||
wrapper: wrapperFor(connection, false),
|
||||
})
|
||||
|
||||
expect(result.current).toBe('disconnected')
|
||||
})
|
||||
|
||||
it('should track status through connection lifecycle', async () => {
|
||||
it('should track the connection through connect and disconnect', async () => {
|
||||
const { result } = renderHook(() => useChannelStatus(), {
|
||||
wrapper: createWrapper(true),
|
||||
wrapper: wrapperFor(connection, 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')
|
||||
})
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
/**
|
||||
* 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 { startChannelServer, type ChannelTestServer } from './channelServer'
|
||||
import { describeIntegration, WS_URL } from '../../testing'
|
||||
|
||||
describeIntegration('useChannel (integration)', () => {
|
||||
function wrapperFor(connection: ChannelConnection) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ChannelProvider connection={connection} autoConnect={true}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
describe('useChannel', () => {
|
||||
let server: ChannelTestServer
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await startChannelServer()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
url: server.url,
|
||||
reconnect: false,
|
||||
})
|
||||
})
|
||||
@@ -28,45 +39,102 @@ describeIntegration('useChannel (integration)', () => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ChannelProvider connection={connection} autoConnect={true}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
it('should subscribe to channel when connection is ready', async () => {
|
||||
const { result } = renderHook(
|
||||
() => useChannel<{ room: string }, { text: string }, { text: string }>('chat', { room: 'test' }),
|
||||
{ wrapper: wrapperFor(connection) }
|
||||
)
|
||||
|
||||
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() }
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// Wait for connection to establish
|
||||
await waitFor(() => {
|
||||
// Status should progress from connecting
|
||||
expect(['connecting', 'connected', 'subscribed']).toContain(result.current.status)
|
||||
}, { timeout: 5000 })
|
||||
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)
|
||||
})
|
||||
|
||||
// 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)
|
||||
})
|
||||
it('should accumulate every message the channel echoes back', async () => {
|
||||
const { result } = renderHook(
|
||||
() => useChannel<{ room: string }, { text: string }, { text: string }>('chat', { room: 'accumulate' }),
|
||||
{ wrapper: wrapperFor(connection) }
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
result.current.send({ text: 'one' })
|
||||
result.current.send({ text: 'two' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages).toEqual([{ text: 'one' }, { text: 'two' }])
|
||||
}, { timeout: 5000 })
|
||||
|
||||
result.current.clearMessages()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages).toEqual([])
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('should drop the oldest message once maxMessages is exceeded', async () => {
|
||||
const { result } = renderHook(
|
||||
() => useChannel<{ room: string }, { text: string }, { text: string }>(
|
||||
'chat',
|
||||
{ room: 'bounded' },
|
||||
{ maxMessages: 2 },
|
||||
),
|
||||
{ wrapper: wrapperFor(connection) }
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
result.current.send({ text: 'one' })
|
||||
result.current.send({ text: 'two' })
|
||||
result.current.send({ text: 'three' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages).toEqual([{ text: 'two' }, { text: 'three' }])
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('should stop reporting connected after unsubscribe is acknowledged', async () => {
|
||||
const { result } = renderHook(
|
||||
() => useChannel<{ room: string }, { text: string }, { text: string }>('chat', { room: 'leave' }),
|
||||
{ wrapper: wrapperFor(connection) }
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
result.current.unsubscribe()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connecting')
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
describeIntegration('useChannelLatest (integration)', () => {
|
||||
describe('useChannelLatest', () => {
|
||||
let server: ChannelTestServer
|
||||
let connection: ChannelConnection
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await startChannelServer()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
connection = new ChannelConnection({
|
||||
url: WS_URL,
|
||||
url: server.url,
|
||||
reconnect: false,
|
||||
})
|
||||
})
|
||||
@@ -75,16 +143,44 @@ describeIntegration('useChannelLatest (integration)', () => {
|
||||
connection.disconnect()
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ChannelProvider connection={connection} autoConnect={true}>
|
||||
{children}
|
||||
</ChannelProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
it('should expose latest instead of a message history', async () => {
|
||||
const { result } = renderHook(
|
||||
() => useChannelLatest<{ room: string }, { text: string }, { text: string }>('chat', { room: 'latest' }),
|
||||
{ wrapper: wrapperFor(connection) }
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
expect(result.current.latest).toBeNull()
|
||||
expect(result.current).not.toHaveProperty('messages')
|
||||
expect(typeof result.current.send).toBe('function')
|
||||
expect(typeof result.current.unsubscribe).toBe('function')
|
||||
})
|
||||
|
||||
it('should replace latest with each newly received message', async () => {
|
||||
const { result } = renderHook(
|
||||
() => useChannelLatest<{ room: string }, { text: string }, { text: string }>('chat', { room: 'latest-replace' }),
|
||||
{ wrapper: wrapperFor(connection) }
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('connected')
|
||||
}, { timeout: 5000 })
|
||||
|
||||
result.current.send({ text: 'first' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.latest).toEqual({ text: 'first' })
|
||||
}, { timeout: 5000 })
|
||||
|
||||
result.current.send({ text: 'second' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.latest).toEqual({ text: 'second' })
|
||||
}, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
describeIntegration('useRPC (integration)', () => {
|
||||
@@ -101,34 +197,21 @@ describeIntegration('useRPC (integration)', () => {
|
||||
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() })
|
||||
const { result } = renderHook(() => useRPC(), { wrapper: wrapperFor(connection) })
|
||||
|
||||
// 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() })
|
||||
const { result } = renderHook(() => useRPC(), { wrapper: wrapperFor(connection) })
|
||||
|
||||
// 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' }
|
||||
@@ -139,14 +222,12 @@ describeIntegration('useRPC (integration)', () => {
|
||||
})
|
||||
|
||||
it('should call backend add function via RPC', async () => {
|
||||
const { result } = renderHook(() => useRPC(), { wrapper: createWrapper() })
|
||||
const { result } = renderHook(() => useRPC(), { wrapper: wrapperFor(connection) })
|
||||
|
||||
// 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 }
|
||||
@@ -155,4 +236,3 @@ describeIntegration('useRPC (integration)', () => {
|
||||
expect(response).toHaveProperty('result', 15)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
'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'
|
||||
@@ -33,7 +27,7 @@ export interface UseChannelOptions<TServerMessage> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a channel and receive typed messages.
|
||||
* Subscribe to a channel and accumulate the messages it sends down.
|
||||
*
|
||||
* @param channelName - The registered channel name
|
||||
* @param params - Channel parameters (if required)
|
||||
@@ -42,12 +36,12 @@ export interface UseChannelOptions<TServerMessage> {
|
||||
export function useChannel<
|
||||
TParams = undefined,
|
||||
TServerMessage = unknown,
|
||||
TReactMessage = unknown,
|
||||
TClientMessage = unknown,
|
||||
>(
|
||||
channelName: string,
|
||||
params?: TParams,
|
||||
options: UseChannelOptions<TServerMessage> = {},
|
||||
): ChannelSubscription<TParams, TServerMessage, TReactMessage> {
|
||||
): ChannelSubscription<TParams, TServerMessage, TClientMessage> {
|
||||
const { connection, status: connectionStatus } = useChannelContext()
|
||||
|
||||
const [messages, setMessages] = useState<TServerMessage[]>([])
|
||||
@@ -63,7 +57,6 @@ export function useChannel<
|
||||
const paramsRef = useRef(params)
|
||||
paramsRef.current = params
|
||||
|
||||
// Subscribe on mount / when params change
|
||||
useEffect(() => {
|
||||
if (connectionStatus !== 'connected') {
|
||||
return
|
||||
@@ -71,30 +64,25 @@ export function useChannel<
|
||||
|
||||
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)
|
||||
@@ -102,12 +90,10 @@ export function useChannel<
|
||||
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)
|
||||
}
|
||||
@@ -117,7 +103,6 @@ export function useChannel<
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup: unsubscribe
|
||||
return () => {
|
||||
unsubscribeMessages()
|
||||
|
||||
@@ -129,8 +114,7 @@ export function useChannel<
|
||||
}
|
||||
}, [connection, connectionStatus, channelName, paramsJson, maxMessages])
|
||||
|
||||
// Send function
|
||||
const send = useCallback((message: TReactMessage) => {
|
||||
const send = useCallback((message: TClientMessage) => {
|
||||
if (!subscribed) {
|
||||
console.warn(`[useChannel] Cannot send: not subscribed to ${channelName}`)
|
||||
return
|
||||
@@ -144,7 +128,6 @@ export function useChannel<
|
||||
})
|
||||
}, [connection, channelName, subscribed])
|
||||
|
||||
// Unsubscribe function
|
||||
const unsubscribe = useCallback(() => {
|
||||
connection.send({
|
||||
action: 'unsubscribe',
|
||||
@@ -153,12 +136,11 @@ export function useChannel<
|
||||
})
|
||||
}, [connection, channelName])
|
||||
|
||||
// Clear messages
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([])
|
||||
}, [])
|
||||
|
||||
// Derive status
|
||||
// Subscription confirmation, not socket readiness, is what makes this channel 'connected'
|
||||
const status: ConnectionStatus = !subscribed
|
||||
? connectionStatus === 'connected' ? 'connecting' : connectionStatus
|
||||
: 'connected'
|
||||
@@ -166,27 +148,27 @@ export function useChannel<
|
||||
return {
|
||||
status,
|
||||
messages,
|
||||
send: send as ChannelSubscription<TParams, TServerMessage, TReactMessage>['send'],
|
||||
send: send as ChannelSubscription<TParams, TServerMessage, TClientMessage>['send'],
|
||||
unsubscribe,
|
||||
clearMessages,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only the latest message from a channel (useful for presence, typing indicators)
|
||||
* Subscribe to a channel and keep only the most recently received message.
|
||||
*/
|
||||
export function useChannelLatest<
|
||||
TParams = undefined,
|
||||
TServerMessage = unknown,
|
||||
TReactMessage = unknown,
|
||||
TClientMessage = unknown,
|
||||
>(
|
||||
channelName: string,
|
||||
params?: TParams,
|
||||
options: UseChannelOptions<TServerMessage> = {},
|
||||
): Omit<ChannelSubscription<TParams, TServerMessage, TReactMessage>, 'messages'> & { latest: TServerMessage | null } {
|
||||
): Omit<ChannelSubscription<TParams, TServerMessage, TClientMessage>, 'messages'> & { latest: TServerMessage | null } {
|
||||
const [latest, setLatest] = useState<TServerMessage | null>(null)
|
||||
|
||||
const channel = useChannel<TParams, TServerMessage, TReactMessage>(
|
||||
const channel = useChannel<TParams, TServerMessage, TClientMessage>(
|
||||
channelName,
|
||||
params,
|
||||
{
|
||||
@@ -199,7 +181,6 @@ export function useChannelLatest<
|
||||
},
|
||||
)
|
||||
|
||||
// Explicitly exclude messages to match the documented API
|
||||
const { messages: _, ...rest } = channel
|
||||
|
||||
return {
|
||||
@@ -229,15 +210,7 @@ export interface RPCClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' })
|
||||
* Get an RPC client bound to the provider's connection.
|
||||
*/
|
||||
export function useRPC(): RPCClient {
|
||||
const { connection, status } = useChannelContext()
|
||||
@@ -252,5 +225,4 @@ export function useRPC(): RPCClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export RPCError for convenience
|
||||
export { RPCError }
|
||||
|
||||
@@ -1,64 +1,9 @@
|
||||
/**
|
||||
* 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,
|
||||
@@ -68,7 +13,6 @@ export type {
|
||||
RPCErrorResponse,
|
||||
} from './connection'
|
||||
|
||||
// Types
|
||||
export type {
|
||||
ConnectionStatus,
|
||||
ChannelSubscription,
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
/**
|
||||
* Types for mizan/channels
|
||||
*/
|
||||
|
||||
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected'
|
||||
|
||||
export interface ChannelSubscription<TParams = unknown, TServerMessage = unknown, TReactMessage = unknown> {
|
||||
export interface ChannelSubscription<TParams = unknown, TServerMessage = unknown, TClientMessage = unknown> {
|
||||
/** Current connection status */
|
||||
status: ConnectionStatus
|
||||
|
||||
/** Received messages */
|
||||
/** Messages received from the server */
|
||||
messages: TServerMessage[]
|
||||
|
||||
/** Send a message (if channel accepts ReactMessage) */
|
||||
send: TReactMessage extends never ? never : (message: TReactMessage) => void
|
||||
/** Send a message up to the server; absent when the channel declares no client message */
|
||||
send: TClientMessage extends never ? never : (message: TClientMessage) => void
|
||||
|
||||
/** Unsubscribe from the channel */
|
||||
unsubscribe: () => void
|
||||
@@ -35,9 +31,6 @@ export interface SubscribeOptions {
|
||||
onUnsubscribed?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol messages sent over the WebSocket
|
||||
*/
|
||||
export interface SubscribeAction {
|
||||
action: 'subscribe'
|
||||
channel: string
|
||||
|
||||
@@ -1,127 +1,62 @@
|
||||
/**
|
||||
* 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)
|
||||
* ```
|
||||
* HTTP client factories for Mizan backends. No framework binding — the React
|
||||
* hook wrapper lives in ./react.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
import { getCSRFToken, getCsrfHeaderName, getCsrfCookieName } from '../utils'
|
||||
import { type FunctionErrorResponse } from '../errors'
|
||||
|
||||
export type { FunctionErrorResponse } from '../errors'
|
||||
|
||||
export type {
|
||||
BaseUser,
|
||||
AuthDetails,
|
||||
AuthRoutes,
|
||||
JWTTokens,
|
||||
JWTConfig,
|
||||
JWTState,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Authentication strategy for client-side requests.
|
||||
*/
|
||||
export enum Auth {
|
||||
/** Session cookies with CSRF token */
|
||||
/** Session cookies with CSRF token. */
|
||||
SESSION = 'session',
|
||||
/** JWT Bearer token */
|
||||
/** JWT Bearer token. */
|
||||
JWT = 'jwt',
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie getter interface (matches Next.js cookies() return type).
|
||||
*/
|
||||
/** Matches the shape Next.js `cookies()` returns. */
|
||||
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 */
|
||||
/** Full `name=value; …` 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
|
||||
*/
|
||||
/** @throws {HttpError} When the 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
|
||||
/** Backend URL reachable from the server process. */
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Errors
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Details about an HTTP error.
|
||||
*/
|
||||
export interface HttpErrorDetails {
|
||||
status: number
|
||||
statusText: string
|
||||
@@ -131,9 +66,6 @@ export interface HttpErrorDetails {
|
||||
bodyIsHtml?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when an HTTP request fails.
|
||||
*/
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -144,11 +76,12 @@ export class HttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Internal Utilities
|
||||
// =============================================================================
|
||||
export interface FunctionSuccessResponse<T> {
|
||||
result: T
|
||||
invalidate?: Array<string | { context: string; params: Record<string, any> }>
|
||||
}
|
||||
|
||||
import { getCSRFToken, getCsrfHeaderName, getCsrfCookieName } from '../utils'
|
||||
export type FunctionResponse<T> = FunctionSuccessResponse<T> | FunctionErrorResponse
|
||||
|
||||
interface RequestBuild {
|
||||
request: RequestInit
|
||||
@@ -202,33 +135,32 @@ async function buildHttpError(resp: Response, url: URL | string): Promise<HttpEr
|
||||
details.bodyIsHtml = contentType.includes('text/html')
|
||||
details.bodySnippet = text.slice(0, 500)
|
||||
}
|
||||
} catch {
|
||||
// Ignore body parsing errors
|
||||
} catch (e) {
|
||||
// The HTTP failure is the thing being reported, so a body that will
|
||||
// not parse degrades the detail rather than replacing the error.
|
||||
console.warn(`[mizan] Could not read error body for ${urlStr}:`, e)
|
||||
}
|
||||
|
||||
return new HttpError(`Request failed: ${resp.status} ${resp.statusText}`, details)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CSR Client Factory
|
||||
// =============================================================================
|
||||
function isCookieGetter(cookies: SSRCookies): cookies is CookieGetter {
|
||||
return typeof (cookies as CookieGetter).get === 'function'
|
||||
}
|
||||
|
||||
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 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')
|
||||
* })
|
||||
* Client-side HTTP client. Auth.SESSION attaches the CSRF header from the
|
||||
* cookie; Auth.JWT attaches a Bearer token from `config.getAccessToken`.
|
||||
*/
|
||||
export function createMizanCSRClient(auth: Auth.SESSION, config?: CSRClientConfig): MizanHTTPClient
|
||||
export function createMizanCSRClient(auth: Auth.JWT, config: JWTClientConfig): MizanHTTPClient
|
||||
@@ -253,17 +185,15 @@ export function createMizanCSRClient(
|
||||
}
|
||||
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
|
||||
// A relative base leaves the path already resolvable by fetch in a browser.
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -290,66 +220,12 @@ export function createMizanCSRClient(
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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.
|
||||
* Server-side HTTP client. Forwards the caller's cookies and CSRF token on
|
||||
* every request and never caches the response.
|
||||
*/
|
||||
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 baseUrl = config.baseUrl
|
||||
const { csrf, cookieHeader } = extractCookies(config.cookies)
|
||||
|
||||
return {
|
||||
@@ -424,52 +300,25 @@ export function createMizanSSRClient(config: SSRClientConfig): MizanHTTPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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 }
|
||||
* })
|
||||
* Resolve a CSRF token and cookie header for an SSR request chain, pinging
|
||||
* `/session/` first when the caller arrived without cookies.
|
||||
*/
|
||||
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 url = new URL('/api/mizan/session/', config.baseUrl)
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -485,10 +334,10 @@ export async function ensureMizanSession(config: SSRClientConfig): Promise<{
|
||||
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
|
||||
// Set-Cookie carries the new session; fold it into the header so the rest
|
||||
// of this SSR chain sends it.
|
||||
const setCookieHeaders = resp.headers.getSetCookie?.() ?? []
|
||||
const newCookies = setCookieHeaders.map(c => c.split(';')[0]).join('; ')
|
||||
const combinedCookies = existingCookies
|
||||
@@ -500,25 +349,3 @@ export async function ensureMizanSession(config: SSRClientConfig): Promise<{
|
||||
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
|
||||
|
||||
|
||||
@@ -9,34 +9,17 @@ import {
|
||||
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/')
|
||||
* Memoized client-side Django HTTP client. Auth.SESSION yields a session client
|
||||
* with CSRF handling; Auth.JWT wires the surrounding JWTContext's token getter in.
|
||||
*/
|
||||
export function useMizanCSRClient(auth: Auth, config?: CSRClientConfig): MizanHTTPClient {
|
||||
// Always call useJWT (React hooks must be unconditional)
|
||||
// Returns null when outside JWTContext
|
||||
// Unconditional because React forbids conditional hook calls; useJWT
|
||||
// returns null outside a JWTContext.
|
||||
const jwtContext = useJWT()
|
||||
|
||||
return useMemo(() => {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
/**
|
||||
* Base user type - extend this for your app's user model.
|
||||
*/
|
||||
/** The user fields Mizan itself reads; the index signature carries the rest. */
|
||||
export interface BaseUser {
|
||||
id?: number | string
|
||||
email?: string
|
||||
@@ -10,51 +8,39 @@ export interface BaseUser {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth state derived from user.
|
||||
*/
|
||||
/** Authorization flags derived from a BaseUser. */
|
||||
export interface AuthDetails {
|
||||
isAuthenticated: boolean
|
||||
isStaff: boolean
|
||||
isSuperuser: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for route guards.
|
||||
*/
|
||||
/** Redirect targets a route guard sends unauthorized and authorized visitors to. */
|
||||
export interface AuthRoutes {
|
||||
login: string
|
||||
authenticated: string
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT token structure.
|
||||
*/
|
||||
export interface JWTTokens {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt: number // Unix timestamp in ms
|
||||
/** Unix timestamp in milliseconds. */
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT endpoint configuration.
|
||||
*/
|
||||
export interface JWTConfig {
|
||||
/** Base URL for API calls (default: '' - use relative URLs) */
|
||||
/** Prefix for API calls; empty means relative URLs. */
|
||||
baseUrl?: string
|
||||
/** mizan server function endpoint (default: /api/mizan/call/) */
|
||||
/** Mizan server-function endpoint path. */
|
||||
endpoint?: string
|
||||
/** Seconds before expiry to trigger refresh (default: 30) */
|
||||
/** Seconds before expiry at which a refresh is triggered. */
|
||||
refreshBuffer?: number
|
||||
/** Auto-obtain tokens on mount (default: true) */
|
||||
/** Obtain tokens on mount. */
|
||||
autoObtain?: boolean
|
||||
/** Auto-refresh tokens before expiry (default: true) */
|
||||
/** Refresh tokens before expiry. */
|
||||
autoRefresh?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT state and methods.
|
||||
*/
|
||||
export interface JWTState {
|
||||
tokens: JWTTokens | null
|
||||
isLoading: boolean
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* mizan React Context
|
||||
* React provider for Mizan server functions and contexts.
|
||||
*
|
||||
* 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.
|
||||
* Transport: HTTP unless a call passes transport='websocket', in which case
|
||||
* WebSocket RPC is used while connected and HTTP serves as the fallback.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -70,13 +56,9 @@ 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 functionName - The server function name
|
||||
* @param input - Optional input data for the function
|
||||
* @param transport - Transport mode ('http' or 'websocket', defaults to 'http')
|
||||
* @param transport - 'http' (default) or 'websocket'
|
||||
*/
|
||||
call: <TInput = unknown, TOutput = unknown>(
|
||||
functionName: string,
|
||||
@@ -124,16 +106,14 @@ export interface MizanContextValue {
|
||||
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).
|
||||
* Resolves once the session is initialized (CSRF cookie set). An HTTP call
|
||||
* issued before that resolves would be missing its CSRF header.
|
||||
*/
|
||||
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.
|
||||
* No-op when the context has no mounted provider.
|
||||
*/
|
||||
invalidateContext: (name: string) => Promise<void>
|
||||
|
||||
@@ -145,8 +125,7 @@ export interface MizanContextValue {
|
||||
|
||||
/**
|
||||
* Register a named context provider for invalidation support.
|
||||
* Called by generated context providers on mount.
|
||||
* Returns an unregister function (call on unmount).
|
||||
* Returns an unregister function to call on unmount.
|
||||
*/
|
||||
registerContextProvider: (
|
||||
name: string,
|
||||
@@ -154,20 +133,18 @@ export interface MizanContextValue {
|
||||
) => () => void
|
||||
|
||||
/**
|
||||
* Base URL for HTTP calls (for use by generated context providers).
|
||||
* Base URL for HTTP calls.
|
||||
*/
|
||||
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.
|
||||
* Make an authenticated HTTP request. Resolves JWT Bearer or session
|
||||
* cookie auth and waits for session init before issuing it.
|
||||
*/
|
||||
request: (method: string, path: string, data?: unknown) => Promise<Response>
|
||||
}
|
||||
@@ -182,8 +159,7 @@ export interface MizanProviderProps {
|
||||
hydration?: MizanHydration
|
||||
|
||||
/**
|
||||
* List of context names to auto-fetch if not in hydration.
|
||||
* These will be fetched on mount.
|
||||
* Context names to fetch on mount when absent from hydration.
|
||||
*/
|
||||
contexts?: string[]
|
||||
|
||||
@@ -213,7 +189,7 @@ export interface MizanProviderProps {
|
||||
maxReconnectAttempts?: number
|
||||
|
||||
/**
|
||||
* Custom connection instance (for testing).
|
||||
* Connection instance to use instead of constructing one.
|
||||
*/
|
||||
connection?: ChannelConnection
|
||||
}
|
||||
@@ -248,19 +224,15 @@ export function MizanProvider({
|
||||
// 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
|
||||
// JWT presence selects the auth method: Bearer when available, session otherwise.
|
||||
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
|
||||
@@ -268,7 +240,6 @@ export function MizanProvider({
|
||||
sessionRef.current = { promise, resolve }
|
||||
}
|
||||
|
||||
// Create HTTP client with appropriate auth method
|
||||
const httpClient = useMemo(() => {
|
||||
if (jwt?.getAccessToken) {
|
||||
return createMizanCSRClient(Auth.JWT, {
|
||||
@@ -279,7 +250,6 @@ export function MizanProvider({
|
||||
return createMizanCSRClient(Auth.SESSION, { baseUrl })
|
||||
}, [hasJWT, jwt?.getAccessToken, baseUrl])
|
||||
|
||||
// Create or use provided connection
|
||||
if (!connectionRef.current) {
|
||||
connectionRef.current = providedConnection ?? new ChannelConnection({
|
||||
url: wsUrl,
|
||||
@@ -291,24 +261,23 @@ export function MizanProvider({
|
||||
|
||||
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
|
||||
// An RPCError is the function's own failure, so it converts
|
||||
// to MizanError; anything else is a connection fault and
|
||||
// falls through to the HTTP path below.
|
||||
if (e instanceof RPCError) {
|
||||
throw new MizanError({
|
||||
error: true,
|
||||
@@ -318,7 +287,6 @@ export function MizanProvider({
|
||||
})
|
||||
}
|
||||
|
||||
// Connection error - fall through to HTTP
|
||||
console.warn(
|
||||
`[mizan] WebSocket RPC failed for '${functionName}', falling back to HTTP:`,
|
||||
e
|
||||
@@ -326,7 +294,6 @@ export function MizanProvider({
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for session init (CSRF cookie) before making HTTP requests
|
||||
await sessionRef.current!.promise
|
||||
|
||||
const response = await httpClient.request(
|
||||
@@ -341,7 +308,6 @@ export function MizanProvider({
|
||||
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') {
|
||||
@@ -359,7 +325,6 @@ export function MizanProvider({
|
||||
[connection, baseUrl, httpClient]
|
||||
)
|
||||
|
||||
// Get cached context data
|
||||
const getContext = useCallback(
|
||||
<T = unknown>(name: string): T | undefined => {
|
||||
return contextStore[name] as T | undefined
|
||||
@@ -367,7 +332,6 @@ export function MizanProvider({
|
||||
[contextStore]
|
||||
)
|
||||
|
||||
// Refresh a specific context via GET /ctx/<name>/
|
||||
const refreshContext = useCallback(
|
||||
async (name: string): Promise<void> => {
|
||||
try {
|
||||
@@ -375,7 +339,6 @@ export function MizanProvider({
|
||||
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 => {
|
||||
@@ -396,7 +359,6 @@ export function MizanProvider({
|
||||
[call]
|
||||
)
|
||||
|
||||
// Refresh all registered contexts
|
||||
const refreshAllContexts = useCallback(
|
||||
async (): Promise<void> => {
|
||||
await Promise.all(contextNames.map(name => refreshContext(name)))
|
||||
@@ -404,7 +366,6 @@ export function MizanProvider({
|
||||
[contextNames, refreshContext]
|
||||
)
|
||||
|
||||
// Subscribe to context changes
|
||||
const onContextChange = useCallback(
|
||||
(name: string, listener: (data: unknown) => void): (() => void) => {
|
||||
const listeners = contextListenersRef.current.get(name) ?? new Set()
|
||||
@@ -424,7 +385,6 @@ export function MizanProvider({
|
||||
[]
|
||||
)
|
||||
|
||||
// Subscribe to push messages
|
||||
const onPush = useCallback(
|
||||
<T = unknown>(topic: string, listener: PushListener<T>): (() => void) => {
|
||||
const listeners = pushListenersRef.current.get(topic) ?? new Set()
|
||||
@@ -444,13 +404,11 @@ export function MizanProvider({
|
||||
[]
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -482,7 +440,8 @@ export function MizanProvider({
|
||||
}
|
||||
}, [connection, autoConnect])
|
||||
|
||||
// Session init for CSR (fallback if proxy didn't run)
|
||||
// A JWT or an existing CSRF cookie means the session already exists;
|
||||
// otherwise hit /session/ to have the backend set one.
|
||||
useEffect(() => {
|
||||
if (hasJWT || getCSRFToken()) {
|
||||
setSessionReady(true)
|
||||
@@ -497,7 +456,6 @@ export function MizanProvider({
|
||||
})
|
||||
}, [hasJWT, baseUrl])
|
||||
|
||||
// Auto-fetch contexts that weren't hydrated
|
||||
useEffect(() => {
|
||||
if (!sessionReady) return
|
||||
if (!hydration) {
|
||||
@@ -513,7 +471,6 @@ export function MizanProvider({
|
||||
|
||||
const isRPCAvailable = status === 'connected'
|
||||
|
||||
// Named context provider registry for invalidation
|
||||
const contextProvidersRef = useRef<Map<string, { refetch: () => Promise<void> }>>(new Map())
|
||||
|
||||
const registerContextProvider = useCallback(
|
||||
@@ -532,16 +489,15 @@ export function MizanProvider({
|
||||
if (provider) {
|
||||
await provider.refetch()
|
||||
}
|
||||
// If not mounted, no-op — no wasted request
|
||||
// Unmounted context: nothing renders it, so no request is worth issuing.
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
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.
|
||||
// The server already resolved function → context, so these names
|
||||
// are usable directly as invalidation targets.
|
||||
const contexts = new Set(names)
|
||||
await Promise.all(
|
||||
Array.from(contexts).map(ctx => invalidateContext(ctx))
|
||||
@@ -550,7 +506,6 @@ export function MizanProvider({
|
||||
[invalidateContext]
|
||||
)
|
||||
|
||||
// Set context data directly (used by generated providers that fetch bundles)
|
||||
const setContextData = useCallback(
|
||||
(name: string, data: unknown) => {
|
||||
setContextStore(prev => {
|
||||
@@ -571,7 +526,6 @@ export function MizanProvider({
|
||||
[]
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -613,21 +567,7 @@ export function MizanProvider({
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 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')
|
||||
* }
|
||||
* ```
|
||||
* Access the Mizan context value. Throws outside a MizanProvider.
|
||||
*/
|
||||
export function useMizan(): MizanContextValue {
|
||||
const context = useReactContext(MizanContextInternal)
|
||||
@@ -638,17 +578,7 @@ export function useMizan(): MizanContextValue {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')
|
||||
* }
|
||||
* ```
|
||||
* Cached data for a named context, or undefined before it has loaded.
|
||||
*/
|
||||
export function useMizanContext<T = unknown>(name: string): T | undefined {
|
||||
const { getContext } = useMizan()
|
||||
@@ -656,26 +586,10 @@ export function useMizanContext<T = unknown>(name: string): T | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A caller bound to one function name and transport.
|
||||
*
|
||||
* @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')
|
||||
* }
|
||||
* ```
|
||||
* @param transport - 'http' (default) or 'websocket'
|
||||
*/
|
||||
export function useMizanCall<TInput = unknown, TOutput = unknown>(
|
||||
functionName: string,
|
||||
@@ -689,7 +603,7 @@ export function useMizanCall<TInput = unknown, TOutput = unknown>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current WebSocket connection status.
|
||||
* The current WebSocket connection status.
|
||||
*/
|
||||
export function useMizanStatus(): ConnectionStatus {
|
||||
const { status } = useMizan()
|
||||
@@ -697,8 +611,7 @@ export function useMizanStatus(): ConnectionStatus {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to push messages for a topic.
|
||||
* Automatically unsubscribes when the component unmounts.
|
||||
* Subscribe to push messages for a topic, unsubscribing on unmount.
|
||||
*/
|
||||
export function usePush<T = unknown>(
|
||||
topic: string,
|
||||
@@ -711,6 +624,8 @@ export function usePush<T = unknown>(
|
||||
callbackRef.current = callback
|
||||
}, [callback])
|
||||
|
||||
// The ref indirection keeps the subscription stable when the caller passes
|
||||
// a fresh closure on every render.
|
||||
useEffect(() => {
|
||||
const listener: PushListener<T> = (message) => {
|
||||
callbackRef.current(message)
|
||||
@@ -719,31 +634,3 @@ export function usePush<T = unknown>(
|
||||
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
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* mizan Forms - Typed React Form Hooks for Django Server Functions
|
||||
*
|
||||
* This module provides the core form state management that generated
|
||||
* form hooks use. It integrates with mizan server functions for
|
||||
* schema fetching, validation, and submission.
|
||||
*
|
||||
* Users don't use this directly - they use generated typed hooks:
|
||||
*
|
||||
* import { useContactForm } from '@/api/forms'
|
||||
* const form = useContactForm()
|
||||
* form.data.email // typed!
|
||||
* form.set('email', 'x') // typed!
|
||||
* Form state for Mizan server-backed forms: schema fetch, Zod + server
|
||||
* validation, and submit.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -26,8 +16,9 @@ import type { ZodObject, ZodRawShape, ZodError } from 'zod'
|
||||
import { useMizan } from './context'
|
||||
import { MizanError } from './errors'
|
||||
|
||||
// Forms always use HTTP transport because Django Allauth and other auth
|
||||
// systems require full HTTP request semantics (session, cookies, CSRF).
|
||||
// Every call below pins transport to 'http': Django Allauth and the other
|
||||
// auth flows need full HTTP request semantics (session, cookies, CSRF), which
|
||||
// the WebSocket path does not carry.
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -121,7 +112,7 @@ export interface FormOptions {
|
||||
|
||||
/**
|
||||
* Whether to refetch the schema on each validation.
|
||||
* Useful for forms with dynamic choice fields.
|
||||
* Needed when a form carries dynamic choice fields.
|
||||
* @default false (uses schema.meta value)
|
||||
*/
|
||||
refetchSchemaOnValidate?: boolean
|
||||
@@ -131,9 +122,6 @@ export interface FormOptions {
|
||||
* - 'on-submit': Only validate on server during submit (default)
|
||||
* - 'live': Also run debounced server validation on field touch
|
||||
*
|
||||
* Use 'live' for forms with server-only validation rules
|
||||
* (e.g., uniqueness checks, DB lookups).
|
||||
*
|
||||
* @default 'on-submit'
|
||||
*/
|
||||
serverValidation?: 'on-submit' | 'live'
|
||||
@@ -184,7 +172,7 @@ export interface MizanFormState<TData extends Record<string, unknown>> {
|
||||
/** Get form-level errors (non-field errors) */
|
||||
getFormErrors: () => FieldError[]
|
||||
|
||||
/** Set a field value - typed! */
|
||||
/** Set a field value */
|
||||
set: <K extends keyof TData>(field: K, value: TData[K]) => void
|
||||
|
||||
/** Mark a field as touched (triggers validation) */
|
||||
@@ -278,11 +266,9 @@ function transformValidation<TData extends Record<string, unknown>>(
|
||||
|
||||
for (const fieldErrors of raw.errors) {
|
||||
if (fieldErrors.field === '__all__') {
|
||||
// Tag form-level errors with server source
|
||||
form.push(...fieldErrors.errors.map(e => ({ ...e, source: 'server' as const })))
|
||||
} else {
|
||||
const name = fieldErrors.field as keyof TData
|
||||
// Tag field errors with server source
|
||||
fields[name] = fieldErrors.errors.map(e => ({ ...e, source: 'server' as const }))
|
||||
}
|
||||
}
|
||||
@@ -307,7 +293,7 @@ function initializeData<TData extends Record<string, unknown>>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean invalid choice values when schema changes.
|
||||
* Blank out any choice-field value that the current schema no longer offers.
|
||||
*/
|
||||
function cleanInvalidChoices<TData extends Record<string, unknown>>(
|
||||
data: TData,
|
||||
@@ -388,10 +374,10 @@ export interface MizanFormsetState<TData extends Record<string, unknown>> {
|
||||
/** Whether a form can be removed */
|
||||
canRemoveForm: boolean
|
||||
|
||||
/** Get a field value - typed! */
|
||||
/** Get a field value */
|
||||
get: <K extends keyof TData>(formIndex: number, field: K) => TData[K] | undefined
|
||||
|
||||
/** Set a field value - typed! */
|
||||
/** Set a field value */
|
||||
set: <K extends keyof TData>(formIndex: number, field: K, value: TData[K]) => void
|
||||
|
||||
/** Get field schema */
|
||||
@@ -422,17 +408,12 @@ export interface MizanFormsetState<TData extends Record<string, unknown>> {
|
||||
|
||||
/**
|
||||
* Configuration for useMizanFormCore.
|
||||
* This is used by generated hooks - not directly by users.
|
||||
*/
|
||||
export interface FormCoreConfig<TData extends Record<string, unknown>> {
|
||||
/** Form name (used for server function calls: name.schema, name.validate, name.submit) */
|
||||
name: string
|
||||
|
||||
/**
|
||||
* Zod schema for client-side validation.
|
||||
* Generated from Django form field definitions.
|
||||
* Provides instant validation feedback without server round-trip.
|
||||
*/
|
||||
/** Zod schema for client-side validation, checked before any server round-trip. */
|
||||
zodSchema?: ZodObject<ZodRawShape>
|
||||
|
||||
/** Form options */
|
||||
@@ -440,12 +421,8 @@ export interface FormCoreConfig<TData extends Record<string, unknown>> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Core form hook that generated hooks use internally.
|
||||
*
|
||||
* This is NOT meant to be used directly by users.
|
||||
* Use generated typed hooks instead (e.g., useContactForm).
|
||||
*
|
||||
* @internal
|
||||
* Form state machine: loads the schema, tracks data and touched fields, runs
|
||||
* Zod then server validation, and submits.
|
||||
*/
|
||||
export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
config: FormCoreConfig<TData>
|
||||
@@ -483,7 +460,6 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
return { fields: {} as { [K in keyof TData]?: FieldError[] }, form: [] }
|
||||
}
|
||||
|
||||
// Convert Zod errors to our format with source tag
|
||||
const fields = {} as { [K in keyof TData]?: FieldError[] }
|
||||
for (const issue of result.error.issues) {
|
||||
const fieldName = issue.path[0] as keyof TData
|
||||
@@ -529,7 +505,7 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
const rawSchema = await call<{ data?: Record<string, unknown> }, RawFormSchema>(
|
||||
`${name}.schema`,
|
||||
{ data: {} },
|
||||
'http' // Forms always use HTTP
|
||||
'http'
|
||||
)
|
||||
|
||||
if (cancelled) return
|
||||
@@ -562,44 +538,37 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Trigger validation
|
||||
// Strategy: Zod first (instant), then optionally server (for complex rules)
|
||||
// Zod runs first for instant feedback; the server pass only follows when
|
||||
// Zod is clean, since it exists to catch rules Zod cannot express.
|
||||
const triggerValidation = useCallback(async (zodOnly: boolean = false) => {
|
||||
if (!schema) return
|
||||
|
||||
const seq = ++validationSeqRef.current
|
||||
|
||||
// Step 1: Instant Zod validation
|
||||
const zodErrors = validateWithZod(data)
|
||||
if (zodErrors) {
|
||||
setErrors(zodErrors)
|
||||
setPendingFields(new Set())
|
||||
|
||||
// If zodOnly mode, stop here (instant feedback only)
|
||||
if (zodOnly) {
|
||||
return
|
||||
}
|
||||
// If Zod found errors, skip server validation - no point
|
||||
// Server validation is for catching server-only rules (uniqueness, DB lookups)
|
||||
// when client-side validation passes
|
||||
if (Object.keys(zodErrors.fields).length > 0) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Server validation (for complex rules like uniqueness, DB lookups)
|
||||
setValidating(true)
|
||||
|
||||
try {
|
||||
let currentSchema = schema
|
||||
let currentData = data
|
||||
|
||||
// Refetch schema if needed
|
||||
if (shouldRefetchSchema()) {
|
||||
const rawSchema = await call<{ data: TData }, RawFormSchema>(
|
||||
`${name}.schema`,
|
||||
{ data },
|
||||
'http' // Forms always use HTTP
|
||||
'http'
|
||||
)
|
||||
|
||||
if (seq !== validationSeqRef.current) return
|
||||
@@ -607,35 +576,31 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
currentSchema = transformSchema<TData>(rawSchema)
|
||||
setSchema(currentSchema)
|
||||
|
||||
// Clean invalid choice values
|
||||
currentData = cleanInvalidChoices(data, currentSchema)
|
||||
if (JSON.stringify(currentData) !== JSON.stringify(data)) {
|
||||
setData(currentData)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate with server
|
||||
const rawValidation = await call<{ data: TData }, RawFormValidation>(
|
||||
`${name}.validate`,
|
||||
{ data: currentData },
|
||||
'http' // Forms always use HTTP
|
||||
'http'
|
||||
)
|
||||
|
||||
if (seq !== validationSeqRef.current) return
|
||||
|
||||
let serverErrors = transformValidation<TData>(rawValidation)
|
||||
|
||||
// Filter out form-level errors if not showing them
|
||||
if (!shouldShowFormErrors()) {
|
||||
serverErrors = { ...serverErrors, form: [] }
|
||||
}
|
||||
|
||||
// In hybrid mode, merge Zod and server errors
|
||||
// Server errors take precedence per-field (more authoritative)
|
||||
// In hybrid mode the server's per-field verdict overrides Zod's,
|
||||
// being the more authoritative of the two.
|
||||
if (zodErrors && serverValidationMode === 'live') {
|
||||
const mergedFields = { ...zodErrors.fields } as { [K in keyof TData]?: FieldError[] }
|
||||
|
||||
// Server errors override Zod errors per-field
|
||||
for (const [field, errors] of Object.entries(serverErrors.fields)) {
|
||||
if (errors && errors.length > 0) {
|
||||
mergedFields[field as keyof TData] = errors
|
||||
@@ -652,8 +617,8 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
|
||||
setPendingFields(new Set())
|
||||
} catch (err) {
|
||||
// Keep whatever Zod already reported; the server pass is additive.
|
||||
console.debug('Validation error:', err)
|
||||
// On server error, keep Zod errors if any
|
||||
} finally {
|
||||
if (seq === validationSeqRef.current) {
|
||||
setValidating(false)
|
||||
@@ -672,11 +637,9 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
|
||||
if (!shouldLiveValidate()) return
|
||||
|
||||
// Instant Zod validation (no debounce needed)
|
||||
if (zodSchema) {
|
||||
triggerValidation(true) // zodOnly = true - instant feedback
|
||||
triggerValidation(true)
|
||||
|
||||
// If hybrid mode enabled, also schedule debounced server validation
|
||||
if (serverValidationMode === 'live') {
|
||||
const timeouts = touchTimeoutsRef.current
|
||||
const existing = timeouts.get(field)
|
||||
@@ -684,7 +647,7 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
|
||||
const id = window.setTimeout(() => {
|
||||
timeouts.delete(field)
|
||||
triggerValidation(false) // Full validation including server
|
||||
triggerValidation(false)
|
||||
}, debounceMs)
|
||||
|
||||
timeouts.set(field, id)
|
||||
@@ -692,7 +655,8 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
return
|
||||
}
|
||||
|
||||
// Server-only validation (no Zod schema) - use pending state
|
||||
// No Zod schema: the field stays "pending" so no stale error is shown
|
||||
// while the debounced server round-trip is in flight.
|
||||
setPendingFields(prev => new Set(prev).add(field))
|
||||
|
||||
const timeouts = touchTimeoutsRef.current
|
||||
@@ -703,7 +667,7 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
|
||||
const id = window.setTimeout(() => {
|
||||
timeouts.delete(field)
|
||||
triggerValidation(false) // Full validation including server
|
||||
triggerValidation(false)
|
||||
}, debounceMs)
|
||||
|
||||
timeouts.set(field, id)
|
||||
@@ -714,17 +678,14 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
field: keyof TData,
|
||||
options?: { source?: ErrorSource }
|
||||
): FieldError[] => {
|
||||
// Don't show errors for fields that haven't been touched yet
|
||||
if (!touchedFields.has(field as keyof TData & string)) {
|
||||
return []
|
||||
}
|
||||
// Don't show errors for fields with pending server validation
|
||||
if (pendingFields.has(field as keyof TData & string)) {
|
||||
return []
|
||||
}
|
||||
const fieldErrors = errors?.fields[field] ?? []
|
||||
|
||||
// Filter by source if requested
|
||||
if (options?.source) {
|
||||
return fieldErrors.filter(e => e.source === options.source)
|
||||
}
|
||||
@@ -757,7 +718,7 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
const response = await call<TData, FormSubmitPassResponse | FormSubmitFailResponse>(
|
||||
`${name}.submit`,
|
||||
data,
|
||||
'http' // Forms always use HTTP
|
||||
'http'
|
||||
)
|
||||
|
||||
if (response.success) {
|
||||
@@ -768,7 +729,8 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
return { success: false, errors: typedErrors }
|
||||
}
|
||||
} catch (err) {
|
||||
// Handle MizanError with validation details
|
||||
// A validation MizanError carries field errors, so it becomes a
|
||||
// failed result rather than a thrown exception.
|
||||
if (err instanceof MizanError && err.isValidationError()) {
|
||||
const rawFieldErrors = err.getFieldErrors()
|
||||
const fields = {} as { [K in keyof TData]?: FieldError[] }
|
||||
@@ -797,11 +759,9 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
}
|
||||
}, [name, data, call])
|
||||
|
||||
// Computed properties
|
||||
// Only consider errors for touched fields (consistent with getFieldErrors)
|
||||
// Only touched fields count, to stay consistent with getFieldErrors.
|
||||
const hasErrors = useMemo(() => {
|
||||
if (!errors) return false
|
||||
// Check for field errors only in touched fields
|
||||
const hasFieldErrors = Array.from(touchedFields).some(field => {
|
||||
const fieldErrors = errors.fields[field as keyof TData]
|
||||
return fieldErrors && fieldErrors.length > 0
|
||||
@@ -812,7 +772,8 @@ export function useMizanFormCore<TData extends Record<string, unknown>>(
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (errors === null || hasErrors) return false
|
||||
// Also check that all required fields have been touched
|
||||
// An untouched required field has not been validated yet, so the form
|
||||
// cannot be called valid.
|
||||
if (schema) {
|
||||
for (const fieldName of schema.fieldOrder) {
|
||||
const field = schema.fields[fieldName]
|
||||
@@ -879,10 +840,7 @@ export interface FormsetCoreConfig<TData extends Record<string, unknown>> {
|
||||
/** Form name (used for server function calls) */
|
||||
name: string
|
||||
|
||||
/**
|
||||
* Zod schema for client-side validation of individual forms.
|
||||
* Generated from Django form field definitions.
|
||||
*/
|
||||
/** Zod schema for client-side validation of each individual form. */
|
||||
zodSchema?: ZodObject<ZodRawShape>
|
||||
|
||||
/** Initial number of forms */
|
||||
@@ -896,9 +854,8 @@ export interface FormsetCoreConfig<TData extends Record<string, unknown>> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Core formset hook that generated hooks use internally.
|
||||
*
|
||||
* @internal
|
||||
* Formset state machine: a variable-length list of forms sharing one schema
|
||||
* fetch, one validation pass, and one submit.
|
||||
*/
|
||||
export function useMizanFormsetCore<TData extends Record<string, unknown>>(
|
||||
config: FormsetCoreConfig<TData>
|
||||
@@ -937,7 +894,7 @@ export function useMizanFormsetCore<TData extends Record<string, unknown>>(
|
||||
const rawSchema = await call<{ forms: TData[] }, RawFormsetSchema>(
|
||||
`${name}.formset.schema`,
|
||||
{ forms: formsData },
|
||||
'http' // Forms always use HTTP
|
||||
'http'
|
||||
)
|
||||
|
||||
setSchemas(rawSchema.forms.map(raw => transformSchema<TData>(raw)))
|
||||
@@ -967,9 +924,9 @@ export function useMizanFormsetCore<TData extends Record<string, unknown>>(
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, []) // Only on mount
|
||||
}, [])
|
||||
|
||||
// Update schema when form count changes
|
||||
// The backend returns one schema per form, so the count drives a refetch.
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
updateSchema(forms)
|
||||
@@ -993,7 +950,7 @@ export function useMizanFormsetCore<TData extends Record<string, unknown>>(
|
||||
const rawValidation = await call<{ forms: TData[] }, RawFormsetValidation>(
|
||||
`${name}.formset.validate`,
|
||||
{ forms },
|
||||
'http' // Forms always use HTTP
|
||||
'http'
|
||||
)
|
||||
|
||||
if (seq !== validationSeqRef.current) return
|
||||
@@ -1024,11 +981,9 @@ export function useMizanFormsetCore<TData extends Record<string, unknown>>(
|
||||
return updated
|
||||
})
|
||||
|
||||
// Also touch the field
|
||||
const key = `${formIndex}-${String(field)}`
|
||||
setTouchedFields(prev => new Set(prev).add(key))
|
||||
|
||||
// Debounced validation
|
||||
const timeouts = touchTimeoutsRef.current
|
||||
const existing = timeouts.get(key)
|
||||
if (existing) clearTimeout(existing)
|
||||
@@ -1096,15 +1051,14 @@ export function useMizanFormsetCore<TData extends Record<string, unknown>>(
|
||||
validationSeqRef.current++
|
||||
|
||||
try {
|
||||
// Check for files
|
||||
const hasFiles = forms.some(form =>
|
||||
Object.values(form).some(value => value instanceof File)
|
||||
)
|
||||
|
||||
if (hasFiles) {
|
||||
// Use HTTP with FormData for file uploads
|
||||
// Server functions don't support multipart yet
|
||||
throw new Error('File uploads in formsets require HTTP transport (not yet implemented)')
|
||||
// The server-function wire format is JSON; a File needs
|
||||
// multipart, which this path does not construct.
|
||||
throw new Error('File uploads in formsets require a multipart HTTP transport')
|
||||
}
|
||||
|
||||
const response = await call<
|
||||
@@ -1113,7 +1067,7 @@ export function useMizanFormsetCore<TData extends Record<string, unknown>>(
|
||||
>(
|
||||
`${name}.formset.submit`,
|
||||
{ forms },
|
||||
'http' // Forms always use HTTP
|
||||
'http'
|
||||
)
|
||||
|
||||
if (response.success) {
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
/**
|
||||
* 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)
|
||||
// React Context & Hooks
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
@@ -93,34 +78,3 @@ export {
|
||||
// ============================================================================
|
||||
|
||||
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'
|
||||
|
||||
@@ -27,6 +27,10 @@ interface JWTContextProps {
|
||||
config?: JWTConfig
|
||||
}
|
||||
|
||||
type TokenOutcome =
|
||||
| { authenticated: true; tokens: JWTTokens }
|
||||
| { authenticated: false }
|
||||
|
||||
export function JWTContext({ children, config }: JWTContextProps) {
|
||||
const cfg = { ...DEFAULT_CONFIG, ...config }
|
||||
|
||||
@@ -34,10 +38,12 @@ export function JWTContext({ children, config }: JWTContextProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Helper to call server functions
|
||||
const callServerFunction = useCallback(async (fn: string, args: Record<string, unknown> = {}) => {
|
||||
const requestTokens = useCallback(async (
|
||||
fn: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<TokenOutcome> => {
|
||||
const url = cfg.baseUrl ? `${cfg.baseUrl}${cfg.endpoint}` : cfg.endpoint
|
||||
const csrfToken = getCSRFToken()
|
||||
const response = await fetch(url, {
|
||||
@@ -46,52 +52,52 @@ export function JWTContext({ children, config }: JWTContextProps) {
|
||||
'Content-Type': 'application/json',
|
||||
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
|
||||
},
|
||||
credentials: 'include', // Include session cookie for CSRF
|
||||
credentials: 'include', // Session cookie is what the CSRF token is checked against.
|
||||
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
|
||||
// FORBIDDEN is how the server names an absent session or a spent
|
||||
// refresh token — an unauthenticated outcome, not a failed call.
|
||||
if (data.code === 'FORBIDDEN') {
|
||||
return { authenticated: false }
|
||||
}
|
||||
const failure = new Error(data.message || 'Server function failed')
|
||||
;(failure as Error & { code?: string; details?: unknown }).code = data.code
|
||||
;(failure as Error & { code?: string; details?: unknown }).details = data.details
|
||||
throw failure
|
||||
}
|
||||
|
||||
return data.data
|
||||
return {
|
||||
authenticated: true,
|
||||
tokens: {
|
||||
accessToken: data.data.access_token,
|
||||
refreshToken: data.data.refresh_token,
|
||||
expiresAt: Date.now() + data.data.expires_in * 1000,
|
||||
},
|
||||
}
|
||||
}, [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)
|
||||
const outcome = await requestTokens('jwt_obtain')
|
||||
const next = outcome.authenticated ? outcome.tokens : null
|
||||
setTokens(next)
|
||||
return next
|
||||
} catch (err) {
|
||||
const failure = err instanceof Error ? err : new Error(String(err))
|
||||
setError(failure)
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [callServerFunction])
|
||||
}, [requestTokens])
|
||||
|
||||
// Refresh tokens
|
||||
const refreshTokens = useCallback(async (): Promise<JWTTokens | null> => {
|
||||
if (!tokens?.refreshToken) {
|
||||
return null
|
||||
@@ -101,32 +107,21 @@ export function JWTContext({ children, config }: JWTContextProps) {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await callServerFunction('jwt_refresh', {
|
||||
const outcome = await requestTokens('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)
|
||||
const next = outcome.authenticated ? outcome.tokens : null
|
||||
setTokens(next)
|
||||
return next
|
||||
} catch (err) {
|
||||
const failure = err instanceof Error ? err : new Error(String(err))
|
||||
setError(failure)
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [tokens?.refreshToken, callServerFunction])
|
||||
}, [tokens?.refreshToken, requestTokens])
|
||||
|
||||
// Clear tokens
|
||||
const clearTokens = useCallback(() => {
|
||||
setTokens(null)
|
||||
setError(null)
|
||||
@@ -136,13 +131,11 @@ export function JWTContext({ children, config }: JWTContextProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 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()
|
||||
@@ -152,24 +145,20 @@ export function JWTContext({ children, config }: JWTContextProps) {
|
||||
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
|
||||
|
||||
@@ -200,19 +189,14 @@ export function JWTContext({ children, config }: JWTContextProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* JWT state and methods, or null outside JWTContext.
|
||||
*/
|
||||
export function useJWT(): JWTState | null {
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access JWT state with a guarantee it exists.
|
||||
* Throws if used outside JWTContext.
|
||||
* JWT state and methods. Throws outside JWTContext.
|
||||
*/
|
||||
export function useJWTRequired(): JWTState {
|
||||
const context = useContext(Context)
|
||||
@@ -222,7 +206,7 @@ export function useJWTRequired(): JWTState {
|
||||
return context
|
||||
}
|
||||
|
||||
/** Check if JWT is available (tokens obtained) */
|
||||
/** True once tokens have been obtained and no request is in flight. */
|
||||
export function useJWTReady(): boolean {
|
||||
const jwt = useJWT()
|
||||
if (!jwt) return false
|
||||
|
||||
@@ -1,44 +1,45 @@
|
||||
/**
|
||||
* 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 { vi } from 'vitest'
|
||||
import { ReactNode } from 'react'
|
||||
import { JWTContext, useJWT, useJWTRequired } from '../JWTContext'
|
||||
import { describeIntegration, BACKEND_URL } from '../../testing'
|
||||
import { startJWTServer, type JWTTestServer } from './jwtServer'
|
||||
|
||||
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>
|
||||
)
|
||||
describe('JWTContext', () => {
|
||||
let server: JWTTestServer
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await startJWTServer()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
server.setSession('anonymous')
|
||||
})
|
||||
|
||||
function createWrapper(config?: Parameters<typeof JWTContext>[0]['config']) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<JWTContext config={{ autoObtain: false, baseUrl: server.url, ...config }}>
|
||||
{children}
|
||||
</JWTContext>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describeIntegration('JWTContext (integration)', () => {
|
||||
describe('Hook behavior outside provider', () => {
|
||||
it('should return null when useJWT used outside JWTContext', () => {
|
||||
describe('hooks outside the provider', () => {
|
||||
it('should return null from useJWT', () => {
|
||||
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(() => {})
|
||||
it('should throw from useJWTRequired', () => {
|
||||
// React logs the render failure; silence it so the expected throw
|
||||
// does not print a stack trace.
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useJWTRequired())
|
||||
@@ -48,46 +49,106 @@ describeIntegration('JWTContext (integration)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Token operations with real backend', () => {
|
||||
it('should handle 401 when obtaining tokens as anonymous user', async () => {
|
||||
const { result } = renderHook(() => useJWTRequired(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
describe('obtainTokens', () => {
|
||||
it('should resolve null without an error when the caller has no session', 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()
|
||||
await expect(result.current.obtainTokens()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
expect(result.current.tokens).toBeNull()
|
||||
// 401 is graceful - not an error state
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should store the issued pair when the session authenticates', async () => {
|
||||
server.setSession('authenticated')
|
||||
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
const issued = await result.current.obtainTokens()
|
||||
expect(issued?.accessToken).toMatch(/^access-\d+$/)
|
||||
})
|
||||
|
||||
expect(result.current.tokens?.accessToken).toMatch(/^access-\d+$/)
|
||||
expect(result.current.tokens?.refreshToken).toMatch(/^refresh-\d+$/)
|
||||
expect(result.current.tokens!.expiresAt).toBeGreaterThan(Date.now())
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle 401 when refreshing tokens without valid refresh token', async () => {
|
||||
const { result } = renderHook(() => useJWTRequired(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
it('should surface a non-FORBIDDEN server error through error', async () => {
|
||||
server.setSession('failing')
|
||||
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()
|
||||
await expect(result.current.obtainTokens()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
expect(result.current.error?.message).toBe('Token service unavailable')
|
||||
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(),
|
||||
describe('refreshTokens', () => {
|
||||
it('should resolve null without calling the server when nothing is held', async () => {
|
||||
server.setSession('failing')
|
||||
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.refreshTokens()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should rotate the held pair', async () => {
|
||||
server.setSession('authenticated')
|
||||
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.obtainTokens()
|
||||
})
|
||||
const first = result.current.tokens!
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshTokens()
|
||||
})
|
||||
|
||||
expect(result.current.tokens!.accessToken).not.toBe(first.accessToken)
|
||||
expect(result.current.tokens!.refreshToken).not.toBe(first.refreshToken)
|
||||
})
|
||||
|
||||
it('should drop the held pair once the server rejects the refresh token', async () => {
|
||||
server.setSession('authenticated')
|
||||
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.obtainTokens()
|
||||
})
|
||||
expect(result.current.tokens).not.toBeNull()
|
||||
|
||||
server.setSession('anonymous')
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.refreshTokens()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
expect(result.current.tokens).toBeNull()
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearTokens', () => {
|
||||
it('should drop a held pair', async () => {
|
||||
server.setSession('authenticated')
|
||||
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.obtainTokens()
|
||||
})
|
||||
expect(result.current.tokens).not.toBeNull()
|
||||
|
||||
act(() => {
|
||||
result.current.clearTokens()
|
||||
})
|
||||
@@ -98,55 +159,72 @@ describeIntegration('JWTContext (integration)', () => {
|
||||
})
|
||||
|
||||
describe('getAccessToken', () => {
|
||||
it('should return null when no tokens available', async () => {
|
||||
const { result } = renderHook(() => useJWTRequired(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
it('should return null when nothing is held', async () => {
|
||||
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
|
||||
|
||||
let token: string | null = 'not-null'
|
||||
await act(async () => {
|
||||
token = await result.current.getAccessToken()
|
||||
await expect(result.current.getAccessToken()).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return the held token while it is outside the refresh buffer', async () => {
|
||||
server.setSession('authenticated')
|
||||
const { result } = renderHook(() => useJWTRequired(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.obtainTokens()
|
||||
})
|
||||
const held = result.current.tokens!.accessToken
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.getAccessToken()).resolves.toBe(held)
|
||||
})
|
||||
})
|
||||
|
||||
it('should refresh first when the held token is inside the refresh buffer', async () => {
|
||||
server.setSession('authenticated')
|
||||
const { result } = renderHook(() => useJWTRequired(), {
|
||||
wrapper: createWrapper({ refreshBuffer: 3600, autoRefresh: false }),
|
||||
})
|
||||
|
||||
expect(token).toBeNull()
|
||||
await act(async () => {
|
||||
await result.current.obtainTokens()
|
||||
})
|
||||
const held = result.current.tokens!.accessToken
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.getAccessToken()).resolves.not.toBe(held)
|
||||
})
|
||||
|
||||
expect(result.current.tokens!.accessToken).not.toBe(held)
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoObtain with real backend', () => {
|
||||
it('should attempt auto-obtain and handle 401 for anonymous', async () => {
|
||||
describe('autoObtain', () => {
|
||||
it('should settle with no tokens for an anonymous caller', 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()
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should hold tokens on mount for an authenticated caller', async () => {
|
||||
server.setSession('authenticated')
|
||||
const { result } = renderHook(() => useJWTRequired(), {
|
||||
wrapper: createWrapper({ autoObtain: true }),
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tokens).not.toBeNull()
|
||||
}, { timeout: 5000 })
|
||||
|
||||
expect(result.current.tokens!.accessToken).toMatch(/^access-\d+$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// The schema JSON is emitted into a consuming project's @/api directory, so
|
||||
// this suite only resolves when run from inside such a project.
|
||||
import mizanSchema from '@/api/generated.mizan.schema.json'
|
||||
|
||||
type mizanFunction = {
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
/**
|
||||
* 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 (
|
||||
|
||||
98
frontends/mizan-react/src/jwt/__tests__/jwtServer.ts
Normal file
98
frontends/mizan-react/src/jwt/__tests__/jwtServer.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
|
||||
interface CallFrame {
|
||||
fn: string
|
||||
args: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SessionState = 'anonymous' | 'authenticated' | 'failing'
|
||||
|
||||
export interface JWTTestServer {
|
||||
url: string
|
||||
setSession: (state: SessionState) => void
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
const TOKEN_LIFETIME_SECONDS = 300
|
||||
|
||||
function forbidden(message: string) {
|
||||
return { error: true, code: 'FORBIDDEN', message }
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP server speaking the Mizan call envelope for `jwt_obtain` and
|
||||
* `jwt_refresh`. Each issue rotates the refresh token, so presenting a
|
||||
* superseded one comes back FORBIDDEN.
|
||||
*/
|
||||
export async function startJWTServer(): Promise<JWTTestServer> {
|
||||
let session: SessionState = 'anonymous'
|
||||
let issueCount = 0
|
||||
let liveRefreshToken: string | null = null
|
||||
|
||||
function issue() {
|
||||
issueCount += 1
|
||||
liveRefreshToken = `refresh-${issueCount}`
|
||||
return {
|
||||
data: {
|
||||
access_token: `access-${issueCount}`,
|
||||
refresh_token: liveRefreshToken,
|
||||
expires_in: TOKEN_LIFETIME_SECONDS,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function respond(frame: CallFrame) {
|
||||
if (session === 'failing') {
|
||||
return { error: true, code: 'SERVER_ERROR', message: 'Token service unavailable' }
|
||||
}
|
||||
|
||||
if (frame.fn === 'jwt_obtain') {
|
||||
return session === 'authenticated' ? issue() : forbidden('No session')
|
||||
}
|
||||
|
||||
if (frame.fn === 'jwt_refresh') {
|
||||
if (session !== 'authenticated' || frame.args.refresh_token !== liveRefreshToken) {
|
||||
return forbidden('Refresh token rejected')
|
||||
}
|
||||
return issue()
|
||||
}
|
||||
|
||||
return { error: true, code: 'NOT_FOUND', message: `Unknown function: ${frame.fn}` }
|
||||
}
|
||||
|
||||
const http = createServer((req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
req.on('end', () => {
|
||||
const frame = JSON.parse(Buffer.concat(chunks).toString()) as CallFrame
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end(JSON.stringify(respond(frame)))
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
http.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
|
||||
const { port } = http.address() as AddressInfo
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
setSession: (state: SessionState) => {
|
||||
session = state
|
||||
},
|
||||
// Keep-alive sockets stay counted by the server until they are torn
|
||||
// down, so closeAllConnections() is what lets close() call back.
|
||||
close: () => new Promise<void>((resolve, reject) => {
|
||||
http.closeAllConnections()
|
||||
http.close((httpError) => {
|
||||
if (httpError) {
|
||||
reject(httpError)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,17 @@
|
||||
/**
|
||||
* 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)
|
||||
// The runner's `describe` global is declared locally because the build
|
||||
// tsconfig has no test-runner type package on its include path.
|
||||
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.
|
||||
*/
|
||||
/** `describe` when a live backend is available, `describe.skip` otherwise. */
|
||||
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')
|
||||
@@ -36,7 +19,4 @@ export const BACKEND_URL = (() => {
|
||||
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/'
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
/**
|
||||
* Shared utilities used across mizan-react.
|
||||
*/
|
||||
|
||||
/** Default CSRF cookie name. Configurable via MizanProvider. */
|
||||
/** Default CSRF cookie name. Replaced by configureCsrf. */
|
||||
let _csrfCookieName = 'csrftoken'
|
||||
|
||||
/** Default CSRF header name. Configurable via MizanProvider. */
|
||||
/** Default CSRF header name. Replaced by configureCsrf. */
|
||||
let _csrfHeaderName = 'X-CSRFToken'
|
||||
|
||||
export function configureCsrf(cookieName: string, headerName: string): void {
|
||||
@@ -21,7 +17,7 @@ export function getCsrfHeaderName(): string {
|
||||
return _csrfHeaderName
|
||||
}
|
||||
|
||||
/** Extract CSRF token from cookies. Returns null during SSR. */
|
||||
/** Extract CSRF token from cookies. Returns null when there is no document. */
|
||||
export function getCSRFToken(): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
const match = document.cookie.match(new RegExp(`${_csrfCookieName}=([^;]+)`))
|
||||
|
||||
@@ -16,9 +16,6 @@ export default defineConfig({
|
||||
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',
|
||||
],
|
||||
exclude: ['src/jwt/__tests__/contract.test.ts'],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
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
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
//! `MizanClient` — the kernel entry point.
|
||||
//!
|
||||
//! Mirrors the `configure(opts)` + module-level state in
|
||||
//! `frontends/mizan-base/src/index.ts`, but as an owned struct because
|
||||
//! Rust lacks module-level mutable state. Consumers hold an
|
||||
//! `Arc<MizanClient>` and pass it everywhere the TS code would have
|
||||
//! used the module-level `config`.
|
||||
//!
|
||||
//! Public surface:
|
||||
//! - `MizanClient::new(config)` — build with reqwest cookie jar.
|
||||
//! - `client.fetch_context(name, params)` — async, returns parsed JSON bundle.
|
||||
//! - `client.call(fn_name, args)` — async, applies merge + invalidation
|
||||
//! from the response then returns `result`.
|
||||
//! - `client.register_context(name, params, fetch_fn)` — register an
|
||||
//! instance; returns a `ContextHandle`.
|
||||
//! - `client.invalidate(name)` / `client.invalidate_scoped(name, params)`
|
||||
//! — schedule invalidation via the kernel queue.
|
||||
//! - `client.merge(context, params, slot, value)` — splice a value into
|
||||
//! a context bundle slot.
|
||||
//! `MizanClient` — the kernel entry point. It owns the reqwest client and
|
||||
//! its cookie jar, the context registry, and the invalidation queue.
|
||||
//! Consumers hold an `Arc<MizanClient>` and reach every request path
|
||||
//! through it.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -34,6 +19,9 @@ use crate::transport;
|
||||
|
||||
|
||||
pub struct MizanConfig {
|
||||
/// Absolute URL of the mounted Mizan router. reqwest has no document
|
||||
/// origin to resolve against, so a browser-style relative path will
|
||||
/// not build a client.
|
||||
pub base_url: String,
|
||||
pub session: bool,
|
||||
pub csrf_cookie_name: String,
|
||||
@@ -45,7 +33,7 @@ pub struct MizanConfig {
|
||||
impl Default for MizanConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: "/api/mizan".to_string(),
|
||||
base_url: "http://localhost:8000/api/mizan".to_string(),
|
||||
session: true,
|
||||
csrf_cookie_name: "csrftoken".to_string(),
|
||||
csrf_header_name: "X-CSRFToken".to_string(),
|
||||
@@ -55,8 +43,18 @@ impl Default for MizanConfig {
|
||||
}
|
||||
|
||||
|
||||
/// The CSRF state the cookie jar holds right now. `Unset` is a jar the
|
||||
/// server has not put a CSRF cookie in, and requests go out without the
|
||||
/// header.
|
||||
pub(crate) enum Csrf {
|
||||
Unset,
|
||||
Token(String),
|
||||
}
|
||||
|
||||
|
||||
pub struct MizanClient {
|
||||
config: Arc<MizanConfig>,
|
||||
base: Url,
|
||||
http: reqwest::Client,
|
||||
cookie_jar: Arc<reqwest::cookie::Jar>,
|
||||
registry: Arc<ContextRegistry>,
|
||||
@@ -66,16 +64,30 @@ pub struct MizanClient {
|
||||
|
||||
|
||||
impl MizanClient {
|
||||
/// Build a client. Both the TLS stack and the configured `base_url`
|
||||
/// are resolved here, once, so every request path below reads an
|
||||
/// absolute `Url` and a live HTTP client that exist by construction.
|
||||
pub fn new(config: MizanConfig) -> Arc<Self> {
|
||||
let cookie_jar = Arc::new(reqwest::cookie::Jar::default());
|
||||
let http = reqwest::Client::builder()
|
||||
let http = match reqwest::Client::builder()
|
||||
.cookie_provider(Arc::clone(&cookie_jar))
|
||||
.build()
|
||||
.expect("reqwest client construction");
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(e) => panic!("the rustls TLS backend failed to initialize: {e}"),
|
||||
};
|
||||
let base = match Url::parse(&config.base_url) {
|
||||
Ok(url) => url,
|
||||
Err(e) => panic!(
|
||||
"MizanConfig.base_url must be an absolute URL; got {:?} ({e})",
|
||||
config.base_url
|
||||
),
|
||||
};
|
||||
let registry = Arc::new(ContextRegistry::new());
|
||||
let queue = InvalidationQueue::new(Arc::clone(®istry));
|
||||
Arc::new(Self {
|
||||
config: Arc::new(config),
|
||||
base,
|
||||
http,
|
||||
cookie_jar,
|
||||
registry,
|
||||
@@ -88,6 +100,14 @@ impl MizanClient {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// The absolute URL of `<base_url>/<suffix>`. `Url::set_path` cannot
|
||||
/// fail on a base that already parsed as hierarchical.
|
||||
pub(crate) fn endpoint(&self, suffix: &str) -> Url {
|
||||
let mut url = self.base.clone();
|
||||
url.set_path(&format!("{}/{}", self.base.path().trim_end_matches('/'), suffix));
|
||||
url
|
||||
}
|
||||
|
||||
pub fn http(&self) -> &reqwest::Client {
|
||||
&self.http
|
||||
}
|
||||
@@ -101,65 +121,99 @@ impl MizanClient {
|
||||
}
|
||||
|
||||
/// Hit `/session/` once on first call to bootstrap the CSRF cookie.
|
||||
/// No-op when `config.session == false`. Three attempts with 100ms
|
||||
/// × attempt backoff.
|
||||
pub async fn ensure_session_ready(&self) -> Result<(), MizanError> {
|
||||
/// No-op when `config.session == false`. Three attempts with 100ms ×
|
||||
/// attempt backoff. A bootstrap that never lands a cookie is reported
|
||||
/// on stderr and left at that: subsequent calls proceed without CSRF
|
||||
/// and still succeed against a server that does not require it.
|
||||
pub async fn ensure_session_ready(&self) {
|
||||
if !self.config.session {
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
self.session_ready
|
||||
.get_or_try_init(|| async {
|
||||
if self.read_csrf_cookie().is_some() {
|
||||
return Ok(());
|
||||
.get_or_init(|| async {
|
||||
if let Csrf::Token(_) = self.csrf() {
|
||||
return;
|
||||
}
|
||||
let url = Url::parse(&format!("{}/session/", self.config.base_url.trim_end_matches('/')))
|
||||
.map_err(|e| MizanError::transport(format!("invalid base_url: {e}")))?;
|
||||
for attempt in 0..3 {
|
||||
let res = self.http.get(url.clone()).send().await;
|
||||
if res.is_ok() && self.read_csrf_cookie().is_some() {
|
||||
return Ok(());
|
||||
let url = self.endpoint("session/");
|
||||
for attempt in 0..3u32 {
|
||||
match self.http.get(url.clone()).send().await {
|
||||
Ok(_) => {
|
||||
if let Csrf::Token(_) = self.csrf() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[mizan] session bootstrap attempt {attempt}: {e}"),
|
||||
}
|
||||
if attempt < 2 {
|
||||
tokio::time::sleep(Duration::from_millis(100 * (attempt as u64 + 1))).await;
|
||||
}
|
||||
}
|
||||
// Mirror TS: failing to bootstrap is non-fatal — subsequent
|
||||
// calls proceed without CSRF and may still succeed (e.g.,
|
||||
// FastAPI configs that don't require it).
|
||||
Ok(())
|
||||
eprintln!(
|
||||
"[mizan] session bootstrap did not yield a {:?} cookie; \
|
||||
requests will carry no CSRF header",
|
||||
self.config.csrf_cookie_name
|
||||
);
|
||||
})
|
||||
.await
|
||||
.copied()
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_headers(&self) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
for (name, value) in &self.config.extra_headers {
|
||||
if let (Ok(n), Ok(v)) = (HeaderName::try_from(name.as_str()), HeaderValue::try_from(value.as_str())) {
|
||||
headers.insert(n, v);
|
||||
}
|
||||
self.insert_header(&mut headers, name, value);
|
||||
}
|
||||
if let Some(token) = self.read_csrf_cookie() {
|
||||
if let (Ok(n), Ok(v)) = (
|
||||
HeaderName::try_from(self.config.csrf_header_name.as_str()),
|
||||
HeaderValue::try_from(token.as_str()),
|
||||
) {
|
||||
headers.insert(n, v);
|
||||
match self.csrf() {
|
||||
Csrf::Unset => {}
|
||||
Csrf::Token(token) => {
|
||||
let header_name = self.config.csrf_header_name.clone();
|
||||
self.insert_header(&mut headers, &header_name, &token);
|
||||
}
|
||||
}
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||
headers
|
||||
}
|
||||
|
||||
fn read_csrf_cookie(&self) -> Option<String> {
|
||||
let url = Url::parse(&self.config.base_url).ok()?;
|
||||
let header = self.cookie_jar.cookies(&url)?;
|
||||
let raw = header.to_str().ok()?;
|
||||
/// Add one header, reporting a name or value reqwest refuses rather
|
||||
/// than dropping it into a request that then behaves inexplicably.
|
||||
fn insert_header(&self, headers: &mut HeaderMap, name: &str, value: &str) {
|
||||
let parsed_name = match HeaderName::try_from(name) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
eprintln!("[mizan] header name {name:?} rejected: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match HeaderValue::try_from(value) {
|
||||
Ok(v) => {
|
||||
headers.insert(parsed_name, v);
|
||||
}
|
||||
Err(e) => eprintln!("[mizan] value for header {name:?} rejected: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which CSRF state the jar spells for `base`: the `Cookie` header the
|
||||
/// jar holds for that URL, decoded as ASCII, scanned for the
|
||||
/// configured cookie name. Any reading short of that is `Unset`, and a
|
||||
/// header carrying bytes outside ASCII is reported on stderr.
|
||||
pub(crate) fn csrf(&self) -> Csrf {
|
||||
let header = match self.cookie_jar.cookies(&self.base) {
|
||||
None => return Csrf::Unset,
|
||||
Some(header) => header,
|
||||
};
|
||||
let pairs = match header.to_str() {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
eprintln!("[mizan] cookie header from the server is not valid ASCII: {e}");
|
||||
return Csrf::Unset;
|
||||
}
|
||||
};
|
||||
let needle = format!("{}=", self.config.csrf_cookie_name);
|
||||
raw.split(';')
|
||||
.map(|p| p.trim())
|
||||
.find_map(|p| p.strip_prefix(&needle))
|
||||
.map(|v| v.trim_matches('"').to_string())
|
||||
for part in pairs.split(';') {
|
||||
if let Some(token) = part.trim().strip_prefix(&needle) {
|
||||
return Csrf::Token(token.trim_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
Csrf::Unset
|
||||
}
|
||||
|
||||
// ── High-level API ─────────────────────────────────────────────────
|
||||
@@ -193,3 +247,153 @@ impl MizanClient {
|
||||
self.registry.merge(context, params, slot, value).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn endpoint_appends_to_the_mounted_prefix() {
|
||||
let client = MizanClient::new(MizanConfig {
|
||||
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
|
||||
session: false,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
client.endpoint("call/").as_str(),
|
||||
"http://127.0.0.1:8765/api/mizan/call/"
|
||||
);
|
||||
assert_eq!(
|
||||
client.endpoint("ctx/user/").as_str(),
|
||||
"http://127.0.0.1:8765/api/mizan/ctx/user/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_tolerates_a_trailing_slash_on_the_base() {
|
||||
let client = MizanClient::new(MizanConfig {
|
||||
base_url: "http://127.0.0.1:8765/api/mizan/".to_string(),
|
||||
session: false,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
client.endpoint("session/").as_str(),
|
||||
"http://127.0.0.1:8765/api/mizan/session/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "must be an absolute URL")]
|
||||
fn relative_base_url_is_rejected_at_construction() {
|
||||
MizanClient::new(MizanConfig {
|
||||
base_url: "/api/mizan".to_string(),
|
||||
session: false,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_jar_reads_as_unset() {
|
||||
let client = MizanClient::new(MizanConfig {
|
||||
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
|
||||
session: false,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(matches!(client.csrf(), Csrf::Unset));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seeded_jar_reads_back_the_token() {
|
||||
let client = MizanClient::new(MizanConfig {
|
||||
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
|
||||
session: false,
|
||||
..Default::default()
|
||||
});
|
||||
client
|
||||
.cookie_jar
|
||||
.add_cookie_str("csrftoken=\"abc123\"; Path=/", &client.base);
|
||||
match client.csrf() {
|
||||
Csrf::Unset => panic!("a jar carrying csrftoken must not read as Unset"),
|
||||
Csrf::Token(token) => assert_eq!(token, "abc123"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jar_without_the_configured_name_reads_as_unset() {
|
||||
let client = MizanClient::new(MizanConfig {
|
||||
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
|
||||
session: false,
|
||||
csrf_cookie_name: "othertoken".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
client
|
||||
.cookie_jar
|
||||
.add_cookie_str("csrftoken=abc123; Path=/", &client.base);
|
||||
assert!(matches!(client.csrf(), Csrf::Unset));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_merely_ends_with_the_configured_name_is_not_the_token() {
|
||||
let client = MizanClient::new(MizanConfig {
|
||||
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
|
||||
session: false,
|
||||
..Default::default()
|
||||
});
|
||||
client
|
||||
.cookie_jar
|
||||
.add_cookie_str("xsrfcsrftoken=decoy; Path=/", &client.base);
|
||||
assert!(matches!(client.csrf(), Csrf::Unset));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_is_found_among_several_cookies() {
|
||||
let client = MizanClient::new(MizanConfig {
|
||||
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
|
||||
session: false,
|
||||
..Default::default()
|
||||
});
|
||||
client
|
||||
.cookie_jar
|
||||
.add_cookie_str("sessionid=zzz; Path=/", &client.base);
|
||||
client
|
||||
.cookie_jar
|
||||
.add_cookie_str("csrftoken=abc123; Path=/", &client.base);
|
||||
match client.csrf() {
|
||||
Csrf::Unset => panic!("csrftoken alongside other cookies must still be found"),
|
||||
Csrf::Token(token) => assert_eq!(token, "abc123"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_headers_omits_csrf_when_the_jar_is_empty() {
|
||||
let client = MizanClient::new(MizanConfig {
|
||||
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
|
||||
session: false,
|
||||
..Default::default()
|
||||
});
|
||||
let headers = client.resolve_headers().await;
|
||||
assert!(!headers.contains_key("X-CSRFToken"));
|
||||
match headers.get(ACCEPT) {
|
||||
None => panic!("every request must declare it accepts JSON"),
|
||||
Some(accept) => assert_eq!(accept, "application/json"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_headers_carries_the_token_once_the_jar_holds_it() {
|
||||
let client = MizanClient::new(MizanConfig {
|
||||
base_url: "http://127.0.0.1:8765/api/mizan".to_string(),
|
||||
session: false,
|
||||
..Default::default()
|
||||
});
|
||||
client
|
||||
.cookie_jar
|
||||
.add_cookie_str("csrftoken=abc123; Path=/", &client.base);
|
||||
let headers = client.resolve_headers().await;
|
||||
match headers.get("X-CSRFToken") {
|
||||
None => panic!("a jar carrying csrftoken must produce the CSRF header"),
|
||||
Some(token) => assert_eq!(token, "abc123"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
//! Context registry.
|
||||
//!
|
||||
//! Mirrors the `contexts: Map<string, Map<ParamKey, ContextEntry>>`
|
||||
//! shape in `frontends/mizan-base/src/index.ts`. Each entry holds the
|
||||
//! latest `ContextState`, a `tokio::sync::watch::Sender` for notifying
|
||||
//! subscribers, and a fetch function the registry invokes on demand.
|
||||
//! Keyed `context name → stable_key(params) → entry`. Each entry holds a
|
||||
//! cell carrying the latest `ContextState` and the count of publishes that
|
||||
//! produced it, plus a fetch function the registry invokes on demand.
|
||||
//!
|
||||
//! Subscribers receive a `ContextHandle` whose `rx: watch::Receiver`
|
||||
//! they read from in their own loop. Watch channels overwrite the
|
||||
//! previous value if the receiver hasn't consumed it yet — the render
|
||||
//! loop sees only the latest state on each tick, never an intermediate
|
||||
//! one. The TS kernel achieves the same effect via React's external
|
||||
//! store re-render coalescing.
|
||||
//! Subscribers receive a `ContextHandle` that holds the same cell and
|
||||
//! remembers the count it last read. A burst of publishes between two
|
||||
//! reads therefore collapses into a single advance to the newest state —
|
||||
//! the render loop never sees an intermediate one.
|
||||
|
||||
use std::collections::hash_map::Entry as MapEntry;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{Mutex, RwLock, mpsc, watch};
|
||||
use serde_json::{Map, Value};
|
||||
use tokio::sync::{Mutex, Notify, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::error::MizanError;
|
||||
use crate::merge::merge_into_bundle;
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -59,17 +58,106 @@ pub type FetchFn = Arc<
|
||||
>;
|
||||
|
||||
|
||||
/// The entry's current state and the number of publishes that produced it.
|
||||
struct Published {
|
||||
version: u64,
|
||||
state: ContextStateRaw,
|
||||
}
|
||||
|
||||
|
||||
/// One entry's shared state. Publishers and readers reach the same
|
||||
/// allocation through an `Arc`, so a reader's next state always arrives:
|
||||
/// the cell lives exactly as long as the last side still holding it.
|
||||
struct ContextCell {
|
||||
published: RwLock<Published>,
|
||||
advanced: Notify,
|
||||
}
|
||||
|
||||
|
||||
impl ContextCell {
|
||||
fn new(initial: ContextStateRaw) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
published: RwLock::new(Published { version: 0, state: initial }),
|
||||
advanced: Notify::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn state(&self) -> ContextStateRaw {
|
||||
self.published.read().await.state.clone()
|
||||
}
|
||||
|
||||
async fn version(&self) -> u64 {
|
||||
self.published.read().await.version
|
||||
}
|
||||
|
||||
async fn publish(&self, state: ContextStateRaw) {
|
||||
{
|
||||
let mut published = self.published.write().await;
|
||||
published.version += 1;
|
||||
published.state = state;
|
||||
}
|
||||
self.advanced.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct ContextEntry {
|
||||
params: Value,
|
||||
tx: watch::Sender<ContextStateRaw>,
|
||||
cell: Arc<ContextCell>,
|
||||
fetch_fn: FetchFn,
|
||||
refetch_tx: mpsc::UnboundedSender<()>,
|
||||
/// Cancel signal for the entry's spawned refetch loop. Set when the
|
||||
/// last handle on the entry unregisters.
|
||||
/// Raised to ask the entry's fetch loop for another pass. Several
|
||||
/// raises before the loop wakes drive one fetch.
|
||||
refetch: Arc<Notify>,
|
||||
/// Cancel signal for the entry's spawned fetch loop. Set when the
|
||||
/// entry is unregistered.
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
|
||||
/// Run one entry's fetches. Each raise of `refetch` publishes a Loading
|
||||
/// state, runs the entry's fetch closure, and publishes what it answered.
|
||||
/// The closure is re-read from the entry every pass, so a re-registration
|
||||
/// between passes takes effect.
|
||||
fn spawn_fetch_loop(
|
||||
entry: Arc<Mutex<ContextEntry>>,
|
||||
refetch: Arc<Notify>,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
_ = refetch.notified() => {
|
||||
let (fetch_fn, cell) = {
|
||||
let entry = entry.lock().await;
|
||||
(entry.fetch_fn.clone(), Arc::clone(&entry.cell))
|
||||
};
|
||||
let carried = cell.state().await.data;
|
||||
cell.publish(ContextState {
|
||||
data: carried,
|
||||
status: ContextStatus::Loading,
|
||||
error: None,
|
||||
})
|
||||
.await;
|
||||
let next = match fetch_fn().await {
|
||||
Ok(data) => ContextState {
|
||||
data: Some(data),
|
||||
status: ContextStatus::Success,
|
||||
error: None,
|
||||
},
|
||||
Err(err) => ContextState {
|
||||
data: cell.state().await.data,
|
||||
status: ContextStatus::Error,
|
||||
error: Some(Arc::new(err)),
|
||||
},
|
||||
};
|
||||
cell.publish(next).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
pub struct ContextRegistry {
|
||||
/// Outer key: context name. Inner key: `stable_key(params)`.
|
||||
entries: RwLock<HashMap<String, HashMap<String, Arc<Mutex<ContextEntry>>>>>,
|
||||
@@ -88,6 +176,33 @@ impl ContextRegistry {
|
||||
Self { entries: RwLock::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
/// The entries `(name, key)` selects — one when the pair names a live
|
||||
/// entry, none otherwise. Merge and invalidate directives arrive from
|
||||
/// the server, which names contexts and param scopes this client may
|
||||
/// never have subscribed to, and those select nothing to act on. The
|
||||
/// read lock is taken and released here, so callers hold no lock
|
||||
/// while awaiting an entry's own mutex.
|
||||
async fn entry_at(&self, name: &str, key: &str) -> Vec<Arc<Mutex<ContextEntry>>> {
|
||||
let outer = self.entries.read().await;
|
||||
match outer.get(name) {
|
||||
None => Vec::new(),
|
||||
Some(inner) => match inner.get(key) {
|
||||
None => Vec::new(),
|
||||
Some(entry) => vec![Arc::clone(entry)],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Every entry registered under `name`, across all param scopes. A
|
||||
/// name nobody subscribed to selects none.
|
||||
async fn entries_of(&self, name: &str) -> Vec<Arc<Mutex<ContextEntry>>> {
|
||||
let outer = self.entries.read().await;
|
||||
match outer.get(name) {
|
||||
None => Vec::new(),
|
||||
Some(inner) => inner.values().map(Arc::clone).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register an instance of `(context_name, params)`. Idempotent —
|
||||
/// re-registering the same key returns a handle on the existing
|
||||
/// entry (the fetch_fn closure is replaced so the latest binding
|
||||
@@ -105,83 +220,49 @@ impl ContextRegistry {
|
||||
let mut outer = self.entries.write().await;
|
||||
let inner = outer.entry(name.clone()).or_default();
|
||||
|
||||
if let Some(existing) = inner.get(&key).cloned() {
|
||||
// Update the fetch closure so the latest registration's
|
||||
// closure wins (matches the TS Strict-Mode behavior).
|
||||
{
|
||||
// `Entry` names both reachable states of the slot, so a repeat
|
||||
// registration and a first registration are branches rather than
|
||||
// an absence to test for.
|
||||
let (cell, refetch, cancel) = match inner.entry(key.clone()) {
|
||||
MapEntry::Occupied(occupied) => {
|
||||
let existing = Arc::clone(occupied.get());
|
||||
let mut entry = existing.lock().await;
|
||||
entry.fetch_fn = fetch_fn;
|
||||
(
|
||||
Arc::clone(&entry.cell),
|
||||
Arc::clone(&entry.refetch),
|
||||
entry.cancel.clone(),
|
||||
)
|
||||
}
|
||||
MapEntry::Vacant(slot) => {
|
||||
let initial = match initial_data {
|
||||
Some(data) => ContextState {
|
||||
data: Some(data),
|
||||
status: ContextStatus::Success,
|
||||
error: None,
|
||||
},
|
||||
None => ContextStateRaw::idle(),
|
||||
};
|
||||
let cell = ContextCell::new(initial);
|
||||
let refetch = Arc::new(Notify::new());
|
||||
let cancel = CancellationToken::new();
|
||||
let entry = Arc::new(Mutex::new(ContextEntry {
|
||||
cell: Arc::clone(&cell),
|
||||
fetch_fn,
|
||||
refetch: Arc::clone(&refetch),
|
||||
cancel: cancel.clone(),
|
||||
}));
|
||||
slot.insert(Arc::clone(&entry));
|
||||
spawn_fetch_loop(entry, Arc::clone(&refetch), cancel.clone());
|
||||
(cell, refetch, cancel)
|
||||
}
|
||||
let entry = existing.lock().await;
|
||||
return ContextHandle {
|
||||
rx: entry.tx.subscribe(),
|
||||
refetch_tx: entry.refetch_tx.clone(),
|
||||
cancel: entry.cancel.clone(),
|
||||
registry: Arc::clone(self),
|
||||
name,
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
let initial = match initial_data {
|
||||
Some(data) => ContextState { data: Some(data), status: ContextStatus::Success, error: None },
|
||||
None => ContextStateRaw::idle(),
|
||||
};
|
||||
let (tx, _rx) = watch::channel(initial);
|
||||
let (refetch_tx, mut refetch_rx) = mpsc::unbounded_channel::<()>();
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
let entry = Arc::new(Mutex::new(ContextEntry {
|
||||
params: params.clone(),
|
||||
tx: tx.clone(),
|
||||
fetch_fn: fetch_fn.clone(),
|
||||
refetch_tx: refetch_tx.clone(),
|
||||
cancel: cancel.clone(),
|
||||
}));
|
||||
inner.insert(key.clone(), Arc::clone(&entry));
|
||||
drop(outer);
|
||||
|
||||
// Spawn the entry's refetch loop. The loop owns its own fetch
|
||||
// closure handle resolution via the entry mutex — each tick
|
||||
// reads the latest closure, so updates via re-register apply.
|
||||
let entry_for_task = Arc::clone(&entry);
|
||||
let cancel_for_task = cancel.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel_for_task.cancelled() => break,
|
||||
msg = refetch_rx.recv() => {
|
||||
if msg.is_none() { break; }
|
||||
let (fetch_fn, tx) = {
|
||||
let entry = entry_for_task.lock().await;
|
||||
(entry.fetch_fn.clone(), entry.tx.clone())
|
||||
};
|
||||
// Loading state
|
||||
let cur = tx.borrow().clone();
|
||||
let loading = ContextState { data: cur.data, status: ContextStatus::Loading, error: None };
|
||||
let _ = tx.send(loading);
|
||||
// Drive the fetch
|
||||
match fetch_fn().await {
|
||||
Ok(data) => {
|
||||
let _ = tx.send(ContextState { data: Some(data), status: ContextStatus::Success, error: None });
|
||||
}
|
||||
Err(err) => {
|
||||
let cur = tx.borrow().clone();
|
||||
let _ = tx.send(ContextState {
|
||||
data: cur.data,
|
||||
status: ContextStatus::Error,
|
||||
error: Some(Arc::new(err)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ContextHandle {
|
||||
rx: tx.subscribe(),
|
||||
refetch_tx,
|
||||
seen: cell.version().await,
|
||||
cell,
|
||||
refetch,
|
||||
cancel,
|
||||
registry: Arc::clone(self),
|
||||
name,
|
||||
@@ -189,8 +270,9 @@ impl ContextRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge a value into a context entry's bundle slot. Mirrors the
|
||||
/// TS kernel `merge(context, params, slot, value)` call.
|
||||
/// Splice `value` into the `slot` of the selected entry's bundle and
|
||||
/// publish the result. An entry that has no data yet, or whose bundle
|
||||
/// already matches the merge, produces no notification.
|
||||
pub async fn merge(
|
||||
&self,
|
||||
name: &str,
|
||||
@@ -200,53 +282,39 @@ impl ContextRegistry {
|
||||
) {
|
||||
let key = match params {
|
||||
Some(p) => stable_key(p),
|
||||
None => stable_key(&Value::Object(Default::default())),
|
||||
None => stable_key(&Value::Object(Map::new())),
|
||||
};
|
||||
let entry_handle = {
|
||||
let outer = self.entries.read().await;
|
||||
outer.get(name).and_then(|inner| inner.get(&key)).cloned()
|
||||
};
|
||||
let Some(entry_arc) = entry_handle else { return };
|
||||
let entry = entry_arc.lock().await;
|
||||
let cur = entry.tx.borrow().clone();
|
||||
let Some(bundle) = cur.data.as_ref() else { return };
|
||||
let Some(merged) = crate::merge::merge_into_bundle(bundle, slot, value) else { return };
|
||||
let _ = entry.tx.send(ContextState {
|
||||
data: Some(merged),
|
||||
status: ContextStatus::Success,
|
||||
error: None,
|
||||
});
|
||||
for entry_arc in self.entry_at(name, &key).await {
|
||||
let cell = {
|
||||
let entry = entry_arc.lock().await;
|
||||
Arc::clone(&entry.cell)
|
||||
};
|
||||
let bundle = match cell.state().await.data {
|
||||
None => continue,
|
||||
Some(bundle) => bundle,
|
||||
};
|
||||
let merged = merge_into_bundle(&bundle, slot, value);
|
||||
if merged == bundle {
|
||||
continue;
|
||||
}
|
||||
cell.publish(ContextState {
|
||||
data: Some(merged),
|
||||
status: ContextStatus::Success,
|
||||
error: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger refetch on every entry of `name`.
|
||||
pub async fn invalidate_broad(&self, name: &str) {
|
||||
let entries = {
|
||||
let outer = self.entries.read().await;
|
||||
outer.get(name).map(|inner| inner.values().cloned().collect::<Vec<_>>())
|
||||
};
|
||||
let Some(entries) = entries else { return };
|
||||
for entry in entries {
|
||||
let tx = {
|
||||
let e = entry.lock().await;
|
||||
e.refetch_tx.clone()
|
||||
};
|
||||
let _ = tx.send(());
|
||||
}
|
||||
raise_refetch(self.entries_of(name).await).await;
|
||||
}
|
||||
|
||||
/// Trigger refetch on the single entry matching `(name, params)`.
|
||||
/// Trigger refetch on the entry matching `(name, params)`.
|
||||
pub async fn invalidate_scoped(&self, name: &str, params: &Value) {
|
||||
let key = stable_key(params);
|
||||
let entry_arc = {
|
||||
let outer = self.entries.read().await;
|
||||
outer.get(name).and_then(|inner| inner.get(&key)).cloned()
|
||||
};
|
||||
let Some(entry_arc) = entry_arc else { return };
|
||||
let tx = {
|
||||
let entry = entry_arc.lock().await;
|
||||
entry.refetch_tx.clone()
|
||||
};
|
||||
let _ = tx.send(());
|
||||
raise_refetch(self.entry_at(name, &key).await).await;
|
||||
}
|
||||
|
||||
async fn unregister(&self, name: &str, key: &str) {
|
||||
@@ -264,9 +332,24 @@ impl ContextRegistry {
|
||||
}
|
||||
|
||||
|
||||
/// Ask each selected entry's fetch loop for another pass.
|
||||
async fn raise_refetch(selected: Vec<Arc<Mutex<ContextEntry>>>) {
|
||||
for entry_arc in selected {
|
||||
let refetch = {
|
||||
let entry = entry_arc.lock().await;
|
||||
Arc::clone(&entry.refetch)
|
||||
};
|
||||
refetch.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub struct ContextHandle {
|
||||
pub rx: watch::Receiver<ContextStateRaw>,
|
||||
refetch_tx: mpsc::UnboundedSender<()>,
|
||||
cell: Arc<ContextCell>,
|
||||
/// The publish count this handle has already read. `changed()`
|
||||
/// returns as soon as the cell moves past it.
|
||||
seen: u64,
|
||||
refetch: Arc<Notify>,
|
||||
cancel: CancellationToken,
|
||||
registry: Arc<ContextRegistry>,
|
||||
name: String,
|
||||
@@ -275,14 +358,34 @@ pub struct ContextHandle {
|
||||
|
||||
|
||||
impl ContextHandle {
|
||||
/// Drive a refetch. Returns immediately; the new state lands on
|
||||
/// `rx` once the kernel's refetch task finishes the fetch.
|
||||
/// Drive a refetch. Returns immediately; the new state lands on the
|
||||
/// cell once the entry's fetch loop finishes the fetch.
|
||||
pub fn refetch(&self) {
|
||||
let _ = self.refetch_tx.send(());
|
||||
self.refetch.notify_one();
|
||||
}
|
||||
|
||||
pub fn state(&self) -> ContextStateRaw {
|
||||
self.rx.borrow().clone()
|
||||
pub async fn state(&self) -> ContextStateRaw {
|
||||
self.cell.state().await
|
||||
}
|
||||
|
||||
/// The next state published after the one this handle last read.
|
||||
pub async fn changed(&mut self) -> ContextStateRaw {
|
||||
loop {
|
||||
// Enrol for the next advance before reading the count, so a
|
||||
// publish landing between the read and the await still wakes
|
||||
// this handle.
|
||||
let advanced = self.cell.advanced.notified();
|
||||
tokio::pin!(advanced);
|
||||
advanced.as_mut().enable();
|
||||
{
|
||||
let published = self.cell.published.read().await;
|
||||
if published.version > self.seen {
|
||||
self.seen = published.version;
|
||||
return published.state.clone();
|
||||
}
|
||||
}
|
||||
advanced.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_token(&self) -> CancellationToken {
|
||||
@@ -295,19 +398,20 @@ impl ContextHandle {
|
||||
}
|
||||
|
||||
|
||||
/// Byte-identical to TS `JSON.stringify(params, Object.keys(params).sort())`.
|
||||
///
|
||||
/// Uses `BTreeMap` for deterministic key ordering and serializes via
|
||||
/// `serde_json::to_string` (compact, no whitespace) — matches the TS
|
||||
/// default. Non-object / non-string params (numbers, booleans) pass
|
||||
/// through serde_json's standard JSON representation.
|
||||
/// Compact JSON of `params` with object keys in sorted order, so two
|
||||
/// callers that spell the same params in a different order land on the
|
||||
/// same registry entry.
|
||||
pub fn stable_key(params: &Value) -> String {
|
||||
match params {
|
||||
Value::Object(map) => {
|
||||
let sorted: BTreeMap<&String, &Value> = map.iter().collect();
|
||||
serde_json::to_string(&sorted).unwrap_or_default()
|
||||
let sorted: BTreeMap<&str, &Value> = map.iter().map(|(k, v)| (k.as_str(), v)).collect();
|
||||
let ordered: Map<String, Value> = sorted
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||
.collect();
|
||||
Value::Object(ordered).to_string()
|
||||
}
|
||||
other => serde_json::to_string(other).unwrap_or_default(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +420,45 @@ pub fn stable_key(params: &Value) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
const SETTLE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Read states off `handle` until one reports Success.
|
||||
async fn success_within(handle: &mut ContextHandle) -> ContextStateRaw {
|
||||
let settled = tokio::time::timeout(SETTLE, async {
|
||||
loop {
|
||||
let state = handle.changed().await;
|
||||
if state.status == ContextStatus::Success {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match settled {
|
||||
Ok(state) => state,
|
||||
Err(elapsed) => panic!("no Success state within {SETTLE:?}: {elapsed}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundle a state carries.
|
||||
fn bundle(state: ContextStateRaw) -> Value {
|
||||
match state.data {
|
||||
Some(data) => data,
|
||||
None => panic!("state carries no bundle"),
|
||||
}
|
||||
}
|
||||
|
||||
fn counted_fetch(counter: Arc<AtomicU32>) -> FetchFn {
|
||||
Arc::new(move || {
|
||||
let counter = Arc::clone(&counter);
|
||||
Box::pin(async move {
|
||||
let n = counter.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
Ok(json!({ "count": n }))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_key_sorts_object_keys() {
|
||||
@@ -333,32 +476,144 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn register_and_refetch() {
|
||||
let registry = Arc::new(ContextRegistry::new());
|
||||
let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||
let counter_clone = Arc::clone(&counter);
|
||||
let fetch_fn: FetchFn = Arc::new(move || {
|
||||
let counter = Arc::clone(&counter_clone);
|
||||
Box::pin(async move {
|
||||
let n = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
|
||||
Ok(json!({ "count": n }))
|
||||
})
|
||||
});
|
||||
|
||||
let mut handle = registry.register("test", json!({}), fetch_fn, None).await;
|
||||
let counter = Arc::new(AtomicU32::new(0));
|
||||
let mut handle = registry
|
||||
.register("test", json!({}), counted_fetch(Arc::clone(&counter)), None)
|
||||
.await;
|
||||
handle.refetch();
|
||||
// Poll until success — watch::Receiver::changed() returns once
|
||||
// per "newest value seen" advance, so back-to-back sends from the
|
||||
// refetch task can coalesce into a single notification. The loop
|
||||
// ignores intermediate Loading states and waits for Success.
|
||||
loop {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), handle.rx.changed())
|
||||
.await
|
||||
.expect("changed timed out")
|
||||
.unwrap();
|
||||
if handle.state().status == ContextStatus::Success {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let state = handle.state();
|
||||
assert_eq!(state.data.unwrap()["count"], 1);
|
||||
let state = success_within(&mut handle).await;
|
||||
assert_eq!(bundle(state)["count"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_state_published_between_reads_is_not_missed() {
|
||||
let registry = Arc::new(ContextRegistry::new());
|
||||
let counter = Arc::new(AtomicU32::new(0));
|
||||
let mut handle = registry
|
||||
.register("test", json!({}), counted_fetch(Arc::clone(&counter)), None)
|
||||
.await;
|
||||
handle.refetch();
|
||||
success_within(&mut handle).await;
|
||||
|
||||
// Let the second fetch land in full before the handle asks for it,
|
||||
// so `changed()` has to answer from the recorded advance rather
|
||||
// than from a wakeup it was present for.
|
||||
registry.invalidate_broad("test").await;
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
let state = success_within(&mut handle).await;
|
||||
assert_eq!(bundle(state)["count"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_splices_into_registered_bundle() {
|
||||
let registry = Arc::new(ContextRegistry::new());
|
||||
let fetch_fn: FetchFn = Arc::new(|| {
|
||||
Box::pin(async { Ok(json!({ "user": { "id": 1, "name": "old" } })) })
|
||||
});
|
||||
let mut handle = registry
|
||||
.register("session", json!({}), fetch_fn, None)
|
||||
.await;
|
||||
handle.refetch();
|
||||
success_within(&mut handle).await;
|
||||
|
||||
registry
|
||||
.merge("session", None, "user", &json!({ "id": 1, "name": "new" }))
|
||||
.await;
|
||||
let merged = success_within(&mut handle).await;
|
||||
assert_eq!(bundle(merged)["user"]["name"], "new");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_into_absent_slot_publishes_nothing() {
|
||||
let registry = Arc::new(ContextRegistry::new());
|
||||
let fetch_fn: FetchFn = Arc::new(|| Box::pin(async { Ok(json!({ "user": 1 })) }));
|
||||
let mut handle = registry
|
||||
.register("session", json!({}), fetch_fn, None)
|
||||
.await;
|
||||
handle.refetch();
|
||||
success_within(&mut handle).await;
|
||||
|
||||
registry.merge("session", None, "absent", &json!(42)).await;
|
||||
let quiet =
|
||||
tokio::time::timeout(Duration::from_millis(200), handle.changed()).await;
|
||||
assert!(quiet.is_err(), "merge into an absent slot must not notify");
|
||||
assert_eq!(bundle(handle.state().await), json!({ "user": 1 }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directives_naming_an_unregistered_context_are_inert() {
|
||||
let registry = Arc::new(ContextRegistry::new());
|
||||
registry.merge("never_registered", None, "slot", &json!(1)).await;
|
||||
registry.invalidate_broad("never_registered").await;
|
||||
registry.invalidate_scoped("never_registered", &json!({})).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_directive_naming_an_unregistered_scope_leaves_its_siblings_alone() {
|
||||
let registry = Arc::new(ContextRegistry::new());
|
||||
let counter = Arc::new(AtomicU32::new(0));
|
||||
let mut handle = registry
|
||||
.register(
|
||||
"user",
|
||||
json!({ "id": 1 }),
|
||||
counted_fetch(Arc::clone(&counter)),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
handle.refetch();
|
||||
success_within(&mut handle).await;
|
||||
|
||||
registry.invalidate_scoped("user", &json!({ "id": 99 })).await;
|
||||
registry.merge("user", Some(&json!({ "id": 99 })), "count", &json!(42)).await;
|
||||
let quiet =
|
||||
tokio::time::timeout(Duration::from_millis(200), handle.changed()).await;
|
||||
assert!(quiet.is_err(), "a scope nobody registered selects no entry");
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalidate_broad_reaches_every_param_scope() {
|
||||
let registry = Arc::new(ContextRegistry::new());
|
||||
let counter = Arc::new(AtomicU32::new(0));
|
||||
let mut one = registry
|
||||
.register(
|
||||
"user",
|
||||
json!({ "id": 1 }),
|
||||
counted_fetch(Arc::clone(&counter)),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let mut two = registry
|
||||
.register(
|
||||
"user",
|
||||
json!({ "id": 2 }),
|
||||
counted_fetch(Arc::clone(&counter)),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
registry.invalidate_broad("user").await;
|
||||
success_within(&mut one).await;
|
||||
success_within(&mut two).await;
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unregistered_entry_still_answers_the_handle_it_left_behind() {
|
||||
let registry = Arc::new(ContextRegistry::new());
|
||||
let fetch_fn: FetchFn = Arc::new(|| Box::pin(async { Ok(json!({ "user": 1 })) }));
|
||||
let mut handle = registry
|
||||
.register("session", json!({}), fetch_fn, None)
|
||||
.await;
|
||||
handle.refetch();
|
||||
success_within(&mut handle).await;
|
||||
|
||||
registry.unregister("session", &stable_key(&json!({}))).await;
|
||||
// The cell outlives the registry entry, so the handle keeps
|
||||
// reading the last state rather than losing its publisher.
|
||||
assert_eq!(bundle(handle.state().await), json!({ "user": 1 }));
|
||||
let quiet =
|
||||
tokio::time::timeout(Duration::from_millis(200), handle.changed()).await;
|
||||
assert!(quiet.is_err(), "an unregistered entry publishes nothing more");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Wire error envelope. Mirrors `MizanError` in `frontends/mizan-base/src/index.ts`.
|
||||
//! Wire error envelope.
|
||||
//!
|
||||
//! Two envelope shapes are tolerated:
|
||||
//!
|
||||
@@ -24,23 +24,43 @@ pub struct MizanError {
|
||||
|
||||
impl MizanError {
|
||||
pub fn from_response(status: u16, body: String) -> Self {
|
||||
let parsed = serde_json::from_str::<Envelope>(&body).ok();
|
||||
let (code, message, details) = match parsed {
|
||||
Some(Envelope::Fastapi { error }) => (
|
||||
let (code, message, details) = match serde_json::from_str::<Envelope>(&body) {
|
||||
Ok(Envelope::Fastapi { error }) => (
|
||||
error.code.unwrap_or_else(|| format!("HTTP_{status}")),
|
||||
error.message.unwrap_or_else(|| format!("Mizan call failed ({status})")),
|
||||
error.details,
|
||||
),
|
||||
Some(Envelope::Django { code, message, details, .. }) => (
|
||||
Ok(Envelope::Django { error: true, code, message, details }) => (
|
||||
code.unwrap_or_else(|| format!("HTTP_{status}")),
|
||||
message.unwrap_or_else(|| format!("Mizan call failed ({status})")),
|
||||
details,
|
||||
),
|
||||
None => (
|
||||
format!("HTTP_{status}"),
|
||||
format!("Mizan call failed ({status})"),
|
||||
None,
|
||||
),
|
||||
Ok(Envelope::Django { error: false, .. }) => {
|
||||
eprintln!(
|
||||
"[mizan] {status} body carries \"error\": false, so it declares no \
|
||||
error to report; falling back to HTTP_{status}"
|
||||
);
|
||||
(
|
||||
format!("HTTP_{status}"),
|
||||
format!("Mizan call failed ({status})"),
|
||||
None,
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
// A body matching neither envelope usually means something
|
||||
// other than the Mizan router answered — a proxy page, a
|
||||
// framework debug page. Naming it here is the only place
|
||||
// that fact is visible; `raw_body` carries the body on.
|
||||
eprintln!(
|
||||
"[mizan] {status} body is neither Mizan envelope shape ({e}); \
|
||||
falling back to HTTP_{status}"
|
||||
);
|
||||
(
|
||||
format!("HTTP_{status}"),
|
||||
format!("Mizan call failed ({status})"),
|
||||
None,
|
||||
)
|
||||
}
|
||||
};
|
||||
Self { status, code, message, details, raw_body: body }
|
||||
}
|
||||
@@ -72,8 +92,9 @@ impl std::error::Error for MizanError {}
|
||||
enum Envelope {
|
||||
Fastapi { error: NestedError },
|
||||
Django {
|
||||
// Django form is `{"error": true, "code": ..., "message": ..., "details": ...}`.
|
||||
// `error` is a bool sentinel; the actual fields are siblings.
|
||||
// Django form is `{"error": true, "code": ..., "message": ...}`.
|
||||
// The untagged match needs this key present to pick this arm, and
|
||||
// its value decides whether the body declares an error at all.
|
||||
error: bool,
|
||||
code: Option<String>,
|
||||
message: Option<String>,
|
||||
@@ -111,10 +132,19 @@ mod tests {
|
||||
assert_eq!(e.message, "missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_declaring_no_error_falls_back_to_the_status() {
|
||||
let body = r#"{"error":false,"code":"IGNORED","message":"ignored"}"#;
|
||||
let e = MizanError::from_response(500, body.to_string());
|
||||
assert_eq!(e.code, "HTTP_500");
|
||||
assert_eq!(e.raw_body, body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_on_unparseable_body() {
|
||||
let e = MizanError::from_response(500, "Internal Server Error".to_string());
|
||||
assert_eq!(e.code, "HTTP_500");
|
||||
assert!(e.message.contains("500"));
|
||||
assert_eq!(e.raw_body, "Internal Server Error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
//! Invalidation queue.
|
||||
//!
|
||||
//! Mirrors the TS kernel's `pending` / `pendingScoped` / `flush()` pair
|
||||
//! at `frontends/mizan-base/src/index.ts`. Mutations accumulate
|
||||
//! invalidation targets; the queue batches them and triggers refetches
|
||||
//! on the matching context entries.
|
||||
//! Mutations accumulate invalidation targets — broad (every entry of a
|
||||
//! context name) and scoped (the one entry matching `(name, params)`).
|
||||
//! The queue batches them and drives the matching registry entries to
|
||||
//! refetch.
|
||||
//!
|
||||
//! The TS kernel uses `queueMicrotask(flush)` to batch within a single
|
||||
//! event-loop tick. The Rust equivalent is a `tokio::task::yield_now()`
|
||||
//! debounce: when `invalidate()` is called, push to the queue, and if
|
||||
//! no flush is scheduled spawn a task that yields once then flushes.
|
||||
//! That gives the same "batch within a single async tick" semantics.
|
||||
//! Batching is a `tokio::task::yield_now()` debounce: `invalidate()`
|
||||
//! records the target and, when no flush is already scheduled, spawns a
|
||||
//! task that yields once and then flushes. Everything recorded inside a
|
||||
//! single async tick therefore lands in one flush.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
@@ -76,8 +75,8 @@ impl InvalidationQueue {
|
||||
}
|
||||
let this = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
// Yield once to batch invalidations queued in the same
|
||||
// async tick — equivalent to TS `queueMicrotask`.
|
||||
// Yield once so every target recorded in this async tick is
|
||||
// already in `pending` when the flush reads it.
|
||||
tokio::task::yield_now().await;
|
||||
this.flush().await;
|
||||
this.scheduled.store(false, Ordering::SeqCst);
|
||||
@@ -85,13 +84,13 @@ impl InvalidationQueue {
|
||||
}
|
||||
|
||||
async fn flush(&self) {
|
||||
let snapshot = {
|
||||
let (broad, scoped) = {
|
||||
let mut pending = self.pending.lock().await;
|
||||
let broad = std::mem::take(&mut pending.broad);
|
||||
let scoped = std::mem::take(&mut pending.scoped);
|
||||
(broad, scoped)
|
||||
(
|
||||
std::mem::take(&mut pending.broad),
|
||||
std::mem::take(&mut pending.scoped),
|
||||
)
|
||||
};
|
||||
let (broad, scoped) = snapshot;
|
||||
|
||||
// Broad first — they cover all scoped variants of the same name.
|
||||
for name in &broad {
|
||||
@@ -112,8 +111,12 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::context::{ContextHandle, ContextRegistry, ContextStatus, FetchFn};
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::AtomicU32;
|
||||
use std::time::Duration;
|
||||
|
||||
fn counted_fetch(counter: Arc<std::sync::atomic::AtomicU32>) -> FetchFn {
|
||||
const SETTLE: Duration = Duration::from_secs(2);
|
||||
|
||||
fn counted_fetch(counter: Arc<AtomicU32>) -> FetchFn {
|
||||
Arc::new(move || {
|
||||
let counter = Arc::clone(&counter);
|
||||
Box::pin(async move {
|
||||
@@ -123,12 +126,19 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// Read states off `handle` until one reports Success.
|
||||
async fn wait_for_success(handle: &mut ContextHandle) {
|
||||
loop {
|
||||
handle.rx.changed().await.unwrap();
|
||||
if handle.state().status == ContextStatus::Success {
|
||||
return;
|
||||
let settled = tokio::time::timeout(SETTLE, async {
|
||||
loop {
|
||||
if handle.changed().await.status == ContextStatus::Success {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match settled {
|
||||
Ok(()) => {}
|
||||
Err(elapsed) => panic!("no Success state within {SETTLE:?}: {elapsed}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,8 +146,10 @@ mod tests {
|
||||
async fn broad_invalidate_triggers_refetch() {
|
||||
let registry = Arc::new(ContextRegistry::new());
|
||||
let queue = InvalidationQueue::new(Arc::clone(®istry));
|
||||
let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||
let mut handle = registry.register("user", json!({}), counted_fetch(Arc::clone(&counter)), None).await;
|
||||
let counter = Arc::new(AtomicU32::new(0));
|
||||
let mut handle = registry
|
||||
.register("user", json!({}), counted_fetch(Arc::clone(&counter)), None)
|
||||
.await;
|
||||
handle.refetch();
|
||||
wait_for_success(&mut handle).await;
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
@@ -145,4 +157,26 @@ mod tests {
|
||||
wait_for_success(&mut handle).await;
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_broad_target_absorbs_a_scoped_one_in_the_same_tick() {
|
||||
let registry = Arc::new(ContextRegistry::new());
|
||||
let queue = InvalidationQueue::new(Arc::clone(®istry));
|
||||
let counter = Arc::new(AtomicU32::new(0));
|
||||
let mut handle = registry
|
||||
.register("user", json!({ "id": 1 }), counted_fetch(Arc::clone(&counter)), None)
|
||||
.await;
|
||||
handle.refetch();
|
||||
wait_for_success(&mut handle).await;
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
|
||||
queue.invalidate("user").await;
|
||||
queue.invalidate_scoped("user", json!({ "id": 1 })).await;
|
||||
wait_for_success(&mut handle).await;
|
||||
|
||||
// Both targets name the same entry, so the flush must refetch it
|
||||
// once, not twice.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
//! Mizan client kernel.
|
||||
//!
|
||||
//! Rust port of `@mizan/base` (frontends/mizan-base/src/index.ts). Same
|
||||
//! public surface, same protocol, same wire shape. Consumers — generated
|
||||
//! per-app crates, the GPU worker, the Python `PyMizanClient` — depend
|
||||
//! on this kernel and never construct HTTP requests directly.
|
||||
//!
|
||||
//! Modules:
|
||||
//! - [`client`] — `MizanClient`, `MizanConfig`, session init
|
||||
//! - [`context`] — registry, `ContextState`, `ContextHandle`, `stable_key`
|
||||
//! - [`error`] — `MizanError`, envelope parsing
|
||||
//! - [`transport`] — `mizan_fetch`, `mizan_call`, retry, header resolution
|
||||
//! - [`merge`] — `splice_slot`
|
||||
//! - [`merge`] — `splice_slot`, `merge_into_bundle`
|
||||
//! - [`invalidation`] — `InvalidationQueue`, debounced flush
|
||||
|
||||
pub mod client;
|
||||
|
||||
@@ -1,53 +1,81 @@
|
||||
//! Mutation-driven merge of a value into a context's bundle slot.
|
||||
//!
|
||||
//! Mirrors `spliceSlot` in `frontends/mizan-base/src/index.ts`. The server
|
||||
//! has already resolved which slot the value lands in (by matching the
|
||||
//! mutation's return type against each context function's return type),
|
||||
//! so the kernel does no inference — it writes directly to `bundle[slot]`.
|
||||
//! The server has already resolved which slot the value lands in (by
|
||||
//! matching the mutation's return type against each context function's
|
||||
//! return type), so nothing here infers a slot — it writes directly to
|
||||
//! `bundle[slot]`.
|
||||
//!
|
||||
//! Rules:
|
||||
//! - If the existing slot is an array and the new value is also an array,
|
||||
//! the array replaces the slot wholesale.
|
||||
//! - If the existing slot is an array and the new value is an object with
|
||||
//! an `id` field, upsert by `id` — replace the matching entry in place
|
||||
//! or append.
|
||||
//! Splice rules:
|
||||
//! - Existing slot is an array and the new value is also an array — the
|
||||
//! new array replaces the slot wholesale.
|
||||
//! - Existing slot is an array and the new value is an object with an
|
||||
//! `id` field — upsert by `id`, replacing the matching entry in place
|
||||
//! or appending.
|
||||
//! - Otherwise the slot is replaced with the new value.
|
||||
|
||||
use serde_json::map::Entry;
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
pub fn splice_slot(slot: &Value, value: &Value) -> Value {
|
||||
if let Value::Array(slot_arr) = slot {
|
||||
if let Value::Array(_) = value {
|
||||
return value.clone();
|
||||
}
|
||||
if let Some(id) = value.get("id") {
|
||||
let mut next = slot_arr.clone();
|
||||
let idx = next.iter().position(|item| item.get("id") == Some(id));
|
||||
match idx {
|
||||
Some(i) => next[i] = value.clone(),
|
||||
None => next.push(value.clone()),
|
||||
}
|
||||
return Value::Array(next);
|
||||
}
|
||||
/// Whether `item` is an object whose `id` equals `id`. Anything else —
|
||||
/// a scalar, an array, an object with no `id` — is not a match.
|
||||
fn carries_id(item: &Value, id: &Value) -> bool {
|
||||
match item {
|
||||
Value::Object(fields) => match fields.get("id") {
|
||||
Some(existing) => existing == id,
|
||||
None => false,
|
||||
},
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) | Value::Array(_) => false,
|
||||
}
|
||||
value.clone()
|
||||
}
|
||||
|
||||
|
||||
/// Apply a merge entry to the bundle of a context entry. Returns the new
|
||||
/// bundle, or `None` if the slot wasn't present in the bundle (caller
|
||||
/// should treat that as a no-op so server-driven merges into stale
|
||||
/// caches don't fabricate slots).
|
||||
pub fn merge_into_bundle(bundle: &Value, slot_name: &str, value: &Value) -> Option<Value> {
|
||||
let obj = bundle.as_object()?;
|
||||
if !obj.contains_key(slot_name) {
|
||||
return None;
|
||||
/// Replace the element of `existing` carrying `id`, or append `value`
|
||||
/// when no element carries it.
|
||||
fn upsert_by_id(existing: &[Value], id: &Value, value: &Value) -> Value {
|
||||
let mut next = existing.to_vec();
|
||||
match next.iter().position(|item| carries_id(item, id)) {
|
||||
Some(i) => next[i] = value.clone(),
|
||||
None => next.push(value.clone()),
|
||||
}
|
||||
Value::Array(next)
|
||||
}
|
||||
|
||||
|
||||
pub fn splice_slot(slot: &Value, value: &Value) -> Value {
|
||||
let Value::Array(existing) = slot else {
|
||||
return value.clone();
|
||||
};
|
||||
match value {
|
||||
Value::Array(_) => value.clone(),
|
||||
Value::Object(fields) => match fields.get("id") {
|
||||
Some(id) => upsert_by_id(existing, id, value),
|
||||
None => value.clone(),
|
||||
},
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Apply a merge entry to a context entry's bundle, returning the bundle
|
||||
/// the entry should now hold. A bundle that is not an object, or that
|
||||
/// carries no slot named `slot_name`, comes back unchanged; no slot is
|
||||
/// added that the bundle did not already have.
|
||||
pub fn merge_into_bundle(bundle: &Value, slot_name: &str, value: &Value) -> Value {
|
||||
let Value::Object(obj) = bundle else {
|
||||
return bundle.clone();
|
||||
};
|
||||
let mut next = obj.clone();
|
||||
let spliced = splice_slot(obj.get(slot_name)?, value);
|
||||
next.insert(slot_name.to_string(), spliced);
|
||||
Some(Value::Object(next))
|
||||
// `Entry` names both reachable states of the lookup, so the vacant
|
||||
// case is a branch rather than an absence to test for.
|
||||
match next.entry(slot_name.to_string()) {
|
||||
Entry::Vacant(_) => bundle.clone(),
|
||||
Entry::Occupied(mut slot) => {
|
||||
let spliced = splice_slot(slot.get(), value);
|
||||
slot.insert(spliced);
|
||||
Value::Object(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,17 +119,48 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_into_bundle_skips_missing_slot() {
|
||||
fn an_object_without_an_id_replaces_the_array() {
|
||||
let slot = json!([{"id": 1}]);
|
||||
let value = json!({"name": "no-id"});
|
||||
assert_eq!(splice_slot(&slot, &value), json!({"name": "no-id"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_elements_never_match_an_id() {
|
||||
let slot = json!([1, 2, {"id": 3, "name": "c"}]);
|
||||
let value = json!({"id": 3, "name": "C"});
|
||||
assert_eq!(
|
||||
splice_slot(&slot, &value),
|
||||
json!([1, 2, {"id": 3, "name": "C"}]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_into_bundle_leaves_missing_slot_untouched() {
|
||||
let bundle = json!({"existing": 1});
|
||||
let value = json!(42);
|
||||
assert!(merge_into_bundle(&bundle, "missing", &value).is_none());
|
||||
assert_eq!(merge_into_bundle(&bundle, "missing", &value), bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_into_bundle_leaves_non_object_bundle_untouched() {
|
||||
let bundle = json!([1, 2, 3]);
|
||||
let value = json!(42);
|
||||
assert_eq!(merge_into_bundle(&bundle, "slot", &value), bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_into_bundle_updates_present_slot() {
|
||||
let bundle = json!({"user_profile": {"id": 1, "name": "old"}});
|
||||
let value = json!({"id": 1, "name": "new"});
|
||||
let merged = merge_into_bundle(&bundle, "user_profile", &value).unwrap();
|
||||
let merged = merge_into_bundle(&bundle, "user_profile", &value);
|
||||
assert_eq!(merged["user_profile"]["name"], "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_into_bundle_keeps_sibling_slots() {
|
||||
let bundle = json!({"a": 1, "b": 2});
|
||||
let merged = merge_into_bundle(&bundle, "a", &json!(9));
|
||||
assert_eq!(merged, json!({"a": 9, "b": 2}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
//! PyO3 façade — exposes `MizanClient` to Python as `PyMizanClient`.
|
||||
//!
|
||||
//! Same kernel, same wire. The Python wrapper that the codegen emits
|
||||
//! adds typed methods on top of this client (Pydantic in / Pydantic
|
||||
//! out); this module's job is the GIL boundary plus the async-to-sync
|
||||
//! bridge.
|
||||
//! One tokio multi-thread runtime is owned by the `PyMizanClient`. `call`
|
||||
//! and `fetch_context` drive it under `py.allow_threads`, so the GIL is
|
||||
//! released across the network round-trip. `subscribe_context` spawns a
|
||||
//! tokio task holding the `ContextHandle`; each state it reads re-acquires
|
||||
//! the GIL via `Python::with_gil` to fire the Python callback, and the
|
||||
//! returned `PyContextSubscription` cancels that task.
|
||||
//!
|
||||
//! Architecture:
|
||||
//! - One tokio multi-thread runtime owned by the `PyMizanClient`.
|
||||
//! - `call` / `fetch_context` use `py.allow_threads(|| rt.block_on(...))`
|
||||
//! so the GIL is released across the network round-trip.
|
||||
//! - `subscribe_context` spawns a tokio task that owns a watch
|
||||
//! receiver; on each change the task acquires the GIL via
|
||||
//! `Python::with_gil` and fires the Python callback. The returned
|
||||
//! `CancellationToken` (wrapped as `PyContextSubscription`) lets
|
||||
//! Python cancel the watcher.
|
||||
//! Two perimeters are crossed in this module. `depythonize`, `pythonize`
|
||||
//! and `call1` cross the CPython FFI — they fail inside the interpreter,
|
||||
//! on allocation, on a type CPython will not represent, or on a raise.
|
||||
//! `MizanError` is the HTTP round-trip's own answer, carried in from the
|
||||
//! wire. Nothing else here is fallible.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::{PyDict};
|
||||
use pyo3::types::PyDict;
|
||||
use pythonize::{depythonize, pythonize};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Map, Value};
|
||||
use tokio::runtime::Runtime;
|
||||
use tokio::sync::watch;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::client::{MizanClient, MizanConfig};
|
||||
use crate::context::{ContextStateRaw, ContextStatus};
|
||||
use crate::error::MizanError;
|
||||
|
||||
|
||||
/// A wire failure reaches Python as a `RuntimeError` whose text is
|
||||
/// `MizanError`'s Display — status, code and message.
|
||||
impl From<MizanError> for PyErr {
|
||||
fn from(err: MizanError) -> Self {
|
||||
PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[pyclass]
|
||||
@@ -52,6 +59,9 @@ impl PyContextSubscription {
|
||||
|
||||
#[pymethods]
|
||||
impl PyMizanClient {
|
||||
/// Build the client and the runtime it owns. Both the tokio reactor
|
||||
/// and the kernel's HTTP stack are established here, so every method
|
||||
/// below runs against resources that exist by construction.
|
||||
#[new]
|
||||
#[pyo3(signature = (base_url, *, session = false, csrf_cookie_name = String::from("csrftoken"), csrf_header_name = String::from("X-CSRFToken")))]
|
||||
fn new(
|
||||
@@ -59,9 +69,11 @@ impl PyMizanClient {
|
||||
session: bool,
|
||||
csrf_cookie_name: String,
|
||||
csrf_header_name: String,
|
||||
) -> PyResult<Self> {
|
||||
let rt = Runtime::new()
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("tokio runtime: {e}")))?;
|
||||
) -> Self {
|
||||
let rt = match Runtime::new() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => panic!("the tokio reactor could not be started: {e}"),
|
||||
};
|
||||
let config = MizanConfig {
|
||||
base_url,
|
||||
session,
|
||||
@@ -69,10 +81,10 @@ impl PyMizanClient {
|
||||
csrf_header_name,
|
||||
extra_headers: Vec::new(),
|
||||
};
|
||||
Ok(Self {
|
||||
Self {
|
||||
inner: MizanClient::new(config),
|
||||
rt: Arc::new(rt),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Invoke a mutation or plain function. `args` is a Python dict (or
|
||||
@@ -84,8 +96,7 @@ impl PyMizanClient {
|
||||
let inner = Arc::clone(&self.inner);
|
||||
let result: Value = py.allow_threads(|| {
|
||||
self.rt.block_on(async move { inner.call(&fn_name, args_value).await })
|
||||
})
|
||||
.map_err(mizan_err_to_py)?;
|
||||
})?;
|
||||
pythonize(py, &result)
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("encode result: {e}")))
|
||||
.map(|bound| bound.unbind())
|
||||
@@ -98,8 +109,7 @@ impl PyMizanClient {
|
||||
let inner = Arc::clone(&self.inner);
|
||||
let result: Value = py.allow_threads(|| {
|
||||
self.rt.block_on(async move { inner.fetch_context(&name, ¶ms_value).await })
|
||||
})
|
||||
.map_err(mizan_err_to_py)?;
|
||||
})?;
|
||||
pythonize(py, &result)
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("encode result: {e}")))
|
||||
.map(|bound| bound.unbind())
|
||||
@@ -124,9 +134,9 @@ impl PyMizanClient {
|
||||
let params_value: Value = depythonize(params.as_any())
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("params: {e}")))?;
|
||||
|
||||
// Build a serde-friendly fetch closure that delegates to the
|
||||
// kernel's `fetch_context` (which itself runs the typed HTTP
|
||||
// pipeline). The subscription's refetches go through this.
|
||||
// The subscription's refetches delegate to the kernel's
|
||||
// `fetch_context`, so they run the same typed HTTP pipeline as a
|
||||
// one-shot fetch.
|
||||
let inner_for_fetch = Arc::clone(&self.inner);
|
||||
let name_for_fetch = name.clone();
|
||||
let params_for_fetch = params_value.clone();
|
||||
@@ -138,36 +148,36 @@ impl PyMizanClient {
|
||||
as std::pin::Pin<Box<dyn std::future::Future<Output = _> + Send + 'static>>
|
||||
});
|
||||
|
||||
let watched_name = name.clone();
|
||||
let inner = Arc::clone(&self.inner);
|
||||
let handle = py.allow_threads(|| {
|
||||
let mut handle = py.allow_threads(|| {
|
||||
self.rt.block_on(async move {
|
||||
inner.register_context(name.clone(), params_value, fetch_fn).await
|
||||
inner.register_context(name, params_value, fetch_fn).await
|
||||
})
|
||||
});
|
||||
let cancel = handle.cancel_token();
|
||||
let cancel_for_task = cancel.clone();
|
||||
let callback = Arc::new(callback);
|
||||
let callback_for_task = Arc::clone(&callback);
|
||||
// Drive an initial refetch before destructuring so the first
|
||||
// state lands without requiring the caller to invalidate.
|
||||
// Drive an initial refetch so the first state lands without
|
||||
// requiring the caller to invalidate.
|
||||
handle.refetch();
|
||||
let rx: watch::Receiver<ContextStateRaw> = handle.rx;
|
||||
|
||||
self.rt.spawn(async move {
|
||||
let mut rx = rx;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel_for_task.cancelled() => break,
|
||||
res = rx.changed() => {
|
||||
if res.is_err() { break; }
|
||||
let snapshot = rx.borrow_and_update().clone();
|
||||
state = handle.changed() => {
|
||||
let payload = state_to_json(&state);
|
||||
Python::with_gil(|py| {
|
||||
let dict = match state_to_pydict(py, &snapshot) {
|
||||
Ok(d) => d,
|
||||
Err(e) => { eprintln!("[pyo3_bridge] encode state: {e}"); return; }
|
||||
};
|
||||
if let Err(e) = callback_for_task.call1(py, (dict,)) {
|
||||
eprintln!("[pyo3_bridge] callback raised: {e}");
|
||||
match pythonize(py, &payload) {
|
||||
Ok(obj) => {
|
||||
if let Err(e) = callback.call1(py, (obj,)) {
|
||||
e.print(py);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!(
|
||||
"[pyo3_bridge] context {watched_name:?} state \
|
||||
could not be allocated as a Python object: {e}"
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -178,72 +188,69 @@ impl PyMizanClient {
|
||||
Ok(PyContextSubscription { cancel })
|
||||
}
|
||||
|
||||
/// Schedule a broad invalidation.
|
||||
/// Schedule a broad invalidation. `InvalidationQueue::invalidate` has
|
||||
/// return type `()`: it records the target, and the refetch it causes
|
||||
/// runs later on the flush task. That unit is this method's own
|
||||
/// return value, so Python sees `None` when the target is recorded.
|
||||
fn invalidate(&self, py: Python<'_>, name: String) {
|
||||
let inner = Arc::clone(&self.inner);
|
||||
py.allow_threads(|| {
|
||||
self.rt.block_on(async move { inner.invalidate(name).await })
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/// Schedule a scoped invalidation.
|
||||
/// Schedule a scoped invalidation. `InvalidationQueue::invalidate_scoped`
|
||||
/// likewise has return type `()`; that unit is what `Ok` wraps here.
|
||||
/// Reading `params` out of CPython is the one thing that can fail.
|
||||
fn invalidate_scoped(&self, py: Python<'_>, name: String, params: &Bound<'_, PyDict>) -> PyResult<()> {
|
||||
let params_value: Value = depythonize(params.as_any())
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("params: {e}")))?;
|
||||
let inner = Arc::clone(&self.inner);
|
||||
py.allow_threads(|| {
|
||||
Ok(py.allow_threads(|| {
|
||||
self.rt.block_on(async move { inner.invalidate_scoped(name, params_value).await })
|
||||
});
|
||||
Ok(())
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn state_to_pydict<'py>(py: Python<'py>, state: &ContextStateRaw) -> PyResult<Bound<'py, PyDict>> {
|
||||
let dict = PyDict::new_bound(py);
|
||||
/// The subscription payload as plain JSON. Every branch yields a value,
|
||||
/// so the watcher reaches the FFI crossing with nothing left to check.
|
||||
fn state_to_json(state: &ContextStateRaw) -> Value {
|
||||
let status = match state.status {
|
||||
ContextStatus::Idle => "idle",
|
||||
ContextStatus::Loading => "loading",
|
||||
ContextStatus::Success => "success",
|
||||
ContextStatus::Error => "error",
|
||||
};
|
||||
dict.set_item("status", status)?;
|
||||
match &state.data {
|
||||
Some(v) => {
|
||||
let obj = pythonize(py, v)
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("encode state.data: {e}")))?;
|
||||
dict.set_item("data", obj)?;
|
||||
}
|
||||
None => dict.set_item("data", py.None())?,
|
||||
}
|
||||
match &state.error {
|
||||
let data = match &state.data {
|
||||
Some(v) => v.clone(),
|
||||
None => Value::Null,
|
||||
};
|
||||
let error = match &state.error {
|
||||
None => Value::Null,
|
||||
Some(err) => {
|
||||
let err_dict = PyDict::new_bound(py);
|
||||
err_dict.set_item("status", err.status)?;
|
||||
err_dict.set_item("code", &err.code)?;
|
||||
err_dict.set_item("message", &err.message)?;
|
||||
if let Some(details) = &err.details {
|
||||
let obj = pythonize(py, details)
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(format!("encode error.details: {e}")))?;
|
||||
err_dict.set_item("details", obj)?;
|
||||
} else {
|
||||
err_dict.set_item("details", py.None())?;
|
||||
}
|
||||
dict.set_item("error", err_dict)?;
|
||||
let details = match &err.details {
|
||||
Some(d) => d.clone(),
|
||||
None => Value::Null,
|
||||
};
|
||||
let mut map = Map::new();
|
||||
map.insert("status".into(), Value::from(err.status));
|
||||
map.insert("code".into(), Value::from(err.code.clone()));
|
||||
map.insert("message".into(), Value::from(err.message.clone()));
|
||||
map.insert("details".into(), details);
|
||||
Value::Object(map)
|
||||
}
|
||||
None => dict.set_item("error", py.None())?,
|
||||
}
|
||||
Ok(dict)
|
||||
};
|
||||
let mut out = Map::new();
|
||||
out.insert("status".into(), Value::from(status));
|
||||
out.insert("data".into(), data);
|
||||
out.insert("error".into(), error);
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
|
||||
fn mizan_err_to_py(err: crate::MizanError) -> PyErr {
|
||||
PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("{err}"))
|
||||
}
|
||||
|
||||
|
||||
/// Python extension module entry point. Wheels built via `maturin
|
||||
/// develop --features pyo3` import the module as `mizan_rust`.
|
||||
/// Python extension module entry point. The function name is the imported
|
||||
/// module name — renaming it renames the module Python sees.
|
||||
#[pymodule]
|
||||
fn mizan_rust(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyMizanClient>()?;
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
//! HTTP transport. Mirrors `mizanFetch` and `mizanCall` in
|
||||
//! `frontends/mizan-base/src/index.ts`.
|
||||
//! HTTP transport.
|
||||
//!
|
||||
//! - `mizan_fetch(client, context, params)` → `GET /api/mizan/ctx/<name>/?params`
|
||||
//! - `mizan_call(client, fn_name, args)` → `POST /api/mizan/call/` with
|
||||
//! `{fn, args}` body. On response, applies any `merge` entries first,
|
||||
//! then `invalidate` entries, then returns the `result` field.
|
||||
//!
|
||||
//! Retries: 3 attempts total, 200ms × attempt linear backoff. Retries
|
||||
//! on network errors and 5xx; surfaces 4xx immediately (matches TS).
|
||||
//! Retries: 3 attempts total, 200ms × attempt linear backoff, on network
|
||||
//! errors and 5xx. A 4xx surfaces immediately — it is the server's
|
||||
//! considered answer, not a transient fault.
|
||||
//!
|
||||
//! CSRF: the reqwest cookie jar stores the CSRF cookie from the
|
||||
//! `/session/` bootstrap; on every call we read it via
|
||||
//! `reqwest::cookie::Jar::cookies(&url)` and add it as the configured
|
||||
//! header. Both names come from `MizanConfig`.
|
||||
//! `/session/` bootstrap; every request reads it back out of the jar and
|
||||
//! adds it as the configured header. Both names come from `MizanConfig`.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::{Method, Url};
|
||||
use reqwest::{Method, RequestBuilder, Url};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -30,8 +29,7 @@ const BACKOFF_BASE: Duration = Duration::from_millis(200);
|
||||
|
||||
/// `GET /api/mizan/ctx/<context>/?params`.
|
||||
pub async fn mizan_fetch(client: &MizanClient, context: &str, params: &Value) -> Result<Value, MizanError> {
|
||||
let mut url = Url::parse(&format!("{}/ctx/{}/", client.config().base_url.trim_end_matches('/'), context))
|
||||
.map_err(|e| MizanError::transport(format!("invalid base_url: {e}")))?;
|
||||
let mut url = client.endpoint(&format!("ctx/{context}/"));
|
||||
if let Value::Object(map) = params {
|
||||
let mut qp = url.query_pairs_mut();
|
||||
for (k, v) in map {
|
||||
@@ -51,37 +49,55 @@ pub async fn mizan_fetch(client: &MizanClient, context: &str, params: &Value) ->
|
||||
/// `POST /api/mizan/call/` with `{fn, args}` body. Applies merge +
|
||||
/// invalidation entries from the response before returning `result`.
|
||||
pub async fn mizan_call(client: &MizanClient, fn_name: &str, args: Value) -> Result<Value, MizanError> {
|
||||
let url = Url::parse(&format!("{}/call/", client.config().base_url.trim_end_matches('/')))
|
||||
.map_err(|e| MizanError::transport(format!("invalid base_url: {e}")))?;
|
||||
let url = client.endpoint("call/");
|
||||
let payload = serde_json::json!({ "fn": fn_name, "args": args });
|
||||
let body_bytes = serde_json::to_vec(&payload)
|
||||
.map_err(|e| MizanError::transport(format!("encode: {e}")))?;
|
||||
// `Value`'s Display is the JSON encoder, so a Value we built ourselves
|
||||
// encodes with no failure case to thread.
|
||||
let body_bytes = payload.to_string().into_bytes();
|
||||
let body = request_with_retry(client, Method::POST, url, Some(body_bytes)).await?;
|
||||
|
||||
// The response body is the server's, so decoding it is the wire
|
||||
// perimeter and its failure is the caller's answer.
|
||||
let response: CallResponse = serde_json::from_str(&body)
|
||||
.map_err(|e| MizanError::transport(format!("decode: {e}")))?;
|
||||
|
||||
if let Some(merges) = response.merge {
|
||||
for entry in &merges {
|
||||
client.context_registry()
|
||||
.merge(&entry.context, entry.params.as_ref(), &entry.slot, &entry.value)
|
||||
.await;
|
||||
}
|
||||
// `ContextRegistry::merge` and both `InvalidationQueue` entry points
|
||||
// have return type `()`: an entry naming a context this client never
|
||||
// registered is inert inside them, so these calls report nothing back.
|
||||
for entry in &response.merge {
|
||||
client.context_registry()
|
||||
.merge(&entry.context, entry.params.as_ref(), &entry.slot, &entry.value)
|
||||
.await;
|
||||
}
|
||||
if let Some(invalidations) = response.invalidate {
|
||||
for entry in invalidations {
|
||||
match entry {
|
||||
InvalidateEntry::Broad(name) => {
|
||||
client.invalidation_queue().invalidate(name).await;
|
||||
}
|
||||
InvalidateEntry::Scoped { context, params } => {
|
||||
client.invalidation_queue().invalidate_scoped(context, params).await;
|
||||
}
|
||||
for entry in response.invalidate {
|
||||
match entry {
|
||||
InvalidateEntry::Broad(name) => {
|
||||
client.invalidation_queue().invalidate(name).await
|
||||
}
|
||||
InvalidateEntry::Scoped { context, params } => {
|
||||
client.invalidation_queue().invalidate_scoped(context, params).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response.result.unwrap_or(Value::Null))
|
||||
Ok(response.result)
|
||||
}
|
||||
|
||||
|
||||
/// Send `req` and read its body. Both halves are the network perimeter:
|
||||
/// the send can fail to reach the server and the body can fail to arrive
|
||||
/// or decode, and either is retryable.
|
||||
async fn send_and_read(req: RequestBuilder) -> Result<(u16, String), MizanError> {
|
||||
let res = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| MizanError::transport(e.to_string()))?;
|
||||
let status = res.status().as_u16();
|
||||
let text = res
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| MizanError::transport(format!("response body: {e}")))?;
|
||||
Ok((status, text))
|
||||
}
|
||||
|
||||
|
||||
@@ -91,53 +107,61 @@ async fn request_with_retry(
|
||||
url: Url,
|
||||
body: Option<Vec<u8>>,
|
||||
) -> Result<String, MizanError> {
|
||||
client.ensure_session_ready().await?;
|
||||
client.ensure_session_ready().await;
|
||||
|
||||
let mut last_err: Option<MizanError> = None;
|
||||
for attempt in 0..MAX_ATTEMPTS {
|
||||
let mut attempt: u32 = 0;
|
||||
loop {
|
||||
let headers = client.resolve_headers().await;
|
||||
let mut req = client.http().request(method.clone(), url.clone()).headers(headers);
|
||||
if let Some(bytes) = &body {
|
||||
req = req.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(bytes.clone());
|
||||
}
|
||||
match req.send().await {
|
||||
Ok(res) => {
|
||||
let status = res.status().as_u16();
|
||||
let text = res.text().await.unwrap_or_default();
|
||||
|
||||
// Every path out of this match either returns or names the error
|
||||
// the next attempt would retry, so the loop never carries a
|
||||
// "maybe we have an error by now" slot.
|
||||
let retryable = match send_and_read(req).await {
|
||||
Ok((status, text)) => {
|
||||
if status < 400 {
|
||||
return Ok(text);
|
||||
}
|
||||
if (400..500).contains(&status) {
|
||||
return Err(MizanError::from_response(status, text));
|
||||
}
|
||||
last_err = Some(MizanError::from_response(status, text));
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(MizanError::transport(e.to_string()));
|
||||
MizanError::from_response(status, text)
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
attempt += 1;
|
||||
if attempt == MAX_ATTEMPTS {
|
||||
return Err(retryable);
|
||||
}
|
||||
if attempt + 1 < MAX_ATTEMPTS {
|
||||
tokio::time::sleep(BACKOFF_BASE.saturating_mul(attempt + 1)).await;
|
||||
}
|
||||
tokio::time::sleep(BACKOFF_BASE.saturating_mul(attempt)).await;
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| MizanError::transport("retry budget exhausted")))
|
||||
}
|
||||
|
||||
|
||||
/// A `call/` response. Every field defaults, so a server that omits
|
||||
/// `result`, `merge` or `invalidate` decodes to the same shape as one
|
||||
/// that sends them empty: a null result and nothing to apply.
|
||||
#[derive(Deserialize)]
|
||||
struct CallResponse {
|
||||
result: Option<Value>,
|
||||
#[serde(default)]
|
||||
merge: Option<Vec<MergeEntry>>,
|
||||
result: Value,
|
||||
#[serde(default)]
|
||||
invalidate: Option<Vec<InvalidateEntry>>,
|
||||
merge: Vec<MergeEntry>,
|
||||
#[serde(default)]
|
||||
invalidate: Vec<InvalidateEntry>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MergeEntry {
|
||||
context: String,
|
||||
/// Absent for a merge into the unscoped instance of the context;
|
||||
/// present when the server targets one param scope.
|
||||
#[serde(default)]
|
||||
params: Option<Value>,
|
||||
slot: String,
|
||||
@@ -151,3 +175,57 @@ enum InvalidateEntry {
|
||||
Broad(String),
|
||||
Scoped { context: String, params: Value },
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_response_with_only_a_result_decodes_with_nothing_to_apply() {
|
||||
let r: CallResponse = serde_json::from_str(r#"{"result":{"id":1}}"#).unwrap();
|
||||
assert_eq!(r.result, serde_json::json!({"id": 1}));
|
||||
assert!(r.merge.is_empty());
|
||||
assert!(r.invalidate.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_response_decodes_to_a_null_result() {
|
||||
let r: CallResponse = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(r.result, Value::Null);
|
||||
assert!(r.merge.is_empty());
|
||||
assert!(r.invalidate.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidate_entries_decode_in_both_wire_forms() {
|
||||
let r: CallResponse = serde_json::from_str(
|
||||
r#"{"invalidate":["user",{"context":"cart","params":{"id":2}}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.invalidate.len(), 2);
|
||||
match &r.invalidate[0] {
|
||||
InvalidateEntry::Broad(name) => assert_eq!(name, "user"),
|
||||
InvalidateEntry::Scoped { .. } => panic!("a bare string is a broad target"),
|
||||
}
|
||||
match &r.invalidate[1] {
|
||||
InvalidateEntry::Broad(_) => panic!("an object is a scoped target"),
|
||||
InvalidateEntry::Scoped { context, params } => {
|
||||
assert_eq!(context, "cart");
|
||||
assert_eq!(params, &serde_json::json!({"id": 2}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merge_entry_without_params_targets_the_unscoped_instance() {
|
||||
let r: CallResponse = serde_json::from_str(
|
||||
r#"{"merge":[{"context":"session","slot":"user","value":{"id":1}}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.merge.len(), 1);
|
||||
assert_eq!(r.merge[0].context, "session");
|
||||
assert_eq!(r.merge[0].slot, "user");
|
||||
assert!(r.merge[0].params.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,8 @@
|
||||
/**
|
||||
* @mizan/webview-transport — routes Mizan calls through a VSCode webview's
|
||||
* postMessage channel instead of HTTP fetch or Tauri IPC. Paired with the
|
||||
* extension-host-side Mizan dispatcher (e.g. `MizanHost` in the holomorphic
|
||||
* extension), which receives envelopes via `webview.onDidReceiveMessage`
|
||||
* and posts responses back via `webview.postMessage`.
|
||||
*
|
||||
* Usage (webview side, inside the bundled React/TS app):
|
||||
*
|
||||
* import { configure } from '@mizan/base'
|
||||
* import { webviewTransport } from '@mizan/webview-transport'
|
||||
*
|
||||
* configure({ transport: webviewTransport() })
|
||||
*
|
||||
* The transport keeps the same protocol surface as the HTTP and Tauri
|
||||
* transports (call/fetch envelopes, {result, invalidate, merge} response
|
||||
* shape), so the codegen output and React adapter are unchanged — only
|
||||
* the wire channel differs.
|
||||
*
|
||||
* Envelope shapes (this side ↔ extension-host):
|
||||
*
|
||||
* webview → host:
|
||||
* { kind: 'call', id, fn, args }
|
||||
* { kind: 'fetch', id, context, params? }
|
||||
*
|
||||
* host → webview:
|
||||
* { kind: 'response', id, ok: true, body }
|
||||
* { kind: 'response', id, ok: false, error: { status, body } }
|
||||
*
|
||||
* Correlation by `id` lets multiple in-flight calls share the one
|
||||
* postMessage channel.
|
||||
*/
|
||||
|
||||
import { MizanError, type MizanCallResponse, type MizanTransport } from '@mizan/base'
|
||||
|
||||
// VSCode's webview API — present at runtime, declared globally so the
|
||||
// transport can be authored without pulling vscode types into a generic
|
||||
// frontend package. Returned by acquireVsCodeApi() exactly once per
|
||||
// webview load; subsequent calls throw.
|
||||
// acquireVsCodeApi is injected into the webview page by VSCode itself, so it is
|
||||
// declared here rather than imported. It returns the API object exactly once per
|
||||
// webview load; a second call throws.
|
||||
declare global {
|
||||
function acquireVsCodeApi(): VsCodeApi
|
||||
}
|
||||
@@ -79,6 +45,8 @@ const pending = new Map<string, Pending>()
|
||||
let installed = false
|
||||
let counter = 0
|
||||
|
||||
// One page-level message listener demultiplexes every response by envelope id,
|
||||
// since all in-flight calls share the single postMessage channel.
|
||||
function install(): void {
|
||||
if (installed) return
|
||||
installed = true
|
||||
@@ -111,14 +79,7 @@ function send<T>(env: Envelope): Promise<T> {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Mizan transport that routes through a VSCode webview's
|
||||
* postMessage channel. Install via:
|
||||
*
|
||||
* import { configure } from '@mizan/base'
|
||||
* import { webviewTransport } from '@mizan/webview-transport'
|
||||
* configure({ transport: webviewTransport() })
|
||||
*/
|
||||
/** A MizanTransport whose wire channel is the VSCode webview's postMessage pair. */
|
||||
export function webviewTransport(): MizanTransport {
|
||||
return {
|
||||
async call(fn, args): Promise<MizanCallResponse> {
|
||||
|
||||
Reference in New Issue
Block a user