Flatten to three packages + extract mizan-runtime
packages/
mizan-runtime/ Framework-agnostic state engine (~150 lines)
Context registry, batched invalidation, fetch primitives
mizan-django/ Django server adapter (was packages/mizan-rpc/adapters/django/)
Codegen moved to mizan-django/generate/
mizan-react/ React adapter (was packages/mizan-csr/adapters/react/)
Removed premature abstractions: mizan-ast, mizan-schema, mizan-rpc,
mizan-csr, mizan-ssr stub packages. The actual architecture is three
concrete packages, not five abstract layers.
mizan-runtime implements the v1 spec: registerContext with params,
scoped invalidation via microtask batching, server-driven invalidation
from mutation responses, mizanFetch for context bundles, mizanCall for
mutations.
264 Django + 33 React tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
585
packages/mizan-react/src/client/index.ts
Normal file
585
packages/mizan-react/src/client/index.ts
Normal file
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* mizan/client
|
||||
*
|
||||
* HTTP client factories for Django backends.
|
||||
* Framework-agnostic - works with vanilla JS, React, Vue, etc.
|
||||
*
|
||||
* ## Quick Start
|
||||
*
|
||||
* ### Client-Side (CSR)
|
||||
* ```ts
|
||||
* import { createDjangoCSRClient, Auth } from 'mizan/client'
|
||||
*
|
||||
* // Session-based (cookies + CSRF)
|
||||
* const client = createDjangoCSRClient(Auth.SESSION)
|
||||
*
|
||||
* // JWT-based (Bearer token)
|
||||
* const client = createDjangoCSRClient(Auth.JWT, { getAccessToken })
|
||||
*
|
||||
* const user = await client.json('GET', '/api/accounts/me/')
|
||||
* ```
|
||||
*
|
||||
* ### Server-Side (SSR)
|
||||
* ```ts
|
||||
* import { createDjangoSSRClient } from 'mizan/client'
|
||||
*
|
||||
* const client = createDjangoSSRClient({
|
||||
* cookies: await cookies() // Next.js cookies()
|
||||
* })
|
||||
*
|
||||
* const user = await client.json('GET', '/api/accounts/me/')
|
||||
* ```
|
||||
*
|
||||
* ## React Hooks
|
||||
*
|
||||
* For React, import from `/react`:
|
||||
* ```tsx
|
||||
* import { useDjangoCSRClient, Auth } from 'mizan/client/react'
|
||||
*
|
||||
* const client = useDjangoCSRClient(Auth.SESSION)
|
||||
* ```
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Authentication strategy for client-side requests.
|
||||
*/
|
||||
export enum Auth {
|
||||
/** Session cookies with CSRF token */
|
||||
SESSION = 'session',
|
||||
/** JWT Bearer token */
|
||||
JWT = 'jwt',
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie getter interface (matches Next.js cookies() return type).
|
||||
*/
|
||||
export interface CookieGetter {
|
||||
get(name: string): { name: string; value: string } | undefined
|
||||
getAll(): { name: string; value: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie configuration for SSR requests.
|
||||
* Can be either a cookie getter (like Next.js cookies()) or pre-extracted values.
|
||||
*/
|
||||
export type SSRCookies = CookieGetter | {
|
||||
/** CSRF token value */
|
||||
csrf: string
|
||||
/** Full cookie header string */
|
||||
cookieHeader: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The core HTTP client interface for Django requests.
|
||||
*/
|
||||
export interface DjangoHTTPClient {
|
||||
/**
|
||||
* Make an HTTP request, returning the raw Response.
|
||||
*/
|
||||
request(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<Response>
|
||||
|
||||
/**
|
||||
* Make an HTTP request, parsing the response as JSON.
|
||||
* @throws {HttpError} When response is not ok
|
||||
*/
|
||||
json<T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for CSR client.
|
||||
*/
|
||||
export interface CSRClientConfig {
|
||||
/** Base URL for the Django backend */
|
||||
baseUrl?: string | (() => string)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for JWT-authenticated CSR client.
|
||||
*/
|
||||
export interface JWTClientConfig extends CSRClientConfig {
|
||||
/** Async function that returns the current access token */
|
||||
getAccessToken: () => Promise<string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for SSR client.
|
||||
*/
|
||||
export interface SSRClientConfig {
|
||||
/** Cookies for authentication forwarding */
|
||||
cookies: SSRCookies
|
||||
/** Internal backend URL override (defaults to http://${INTERNAL_BACKEND_HOSTNAME}:8000) */
|
||||
baseUrl?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Errors
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Details about an HTTP error.
|
||||
*/
|
||||
export interface HttpErrorDetails {
|
||||
status: number
|
||||
statusText: string
|
||||
url: string
|
||||
bodyJson?: unknown
|
||||
bodySnippet?: string
|
||||
bodyIsHtml?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when an HTTP request fails.
|
||||
*/
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly details: HttpErrorDetails
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'HttpError'
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Internal Utilities
|
||||
// =============================================================================
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
const value = `; ${document.cookie}`
|
||||
const parts = value.split(`; ${name}=`)
|
||||
if (parts.length === 2) {
|
||||
return parts.pop()?.split(';').shift() ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getCSRFToken(): string | null {
|
||||
return getCookie('csrftoken')
|
||||
}
|
||||
|
||||
interface RequestBuild {
|
||||
request: RequestInit
|
||||
hasBody: boolean
|
||||
}
|
||||
|
||||
function buildRequest(method: string, data?: unknown, headers?: Record<string, string>): RequestBuild {
|
||||
const isBodyMethod = !['GET', 'HEAD'].includes(method.toUpperCase())
|
||||
const hasBody = isBodyMethod && data !== undefined
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
...headers,
|
||||
}
|
||||
|
||||
let body: BodyInit | undefined
|
||||
if (hasBody) {
|
||||
if (data instanceof FormData) {
|
||||
body = data
|
||||
} else {
|
||||
body = JSON.stringify(data)
|
||||
requestHeaders['Content-Type'] = 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
request: {
|
||||
method: method.toUpperCase(),
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
credentials: 'include',
|
||||
},
|
||||
hasBody,
|
||||
}
|
||||
}
|
||||
|
||||
async function buildHttpError(resp: Response, url: URL | string): Promise<HttpError> {
|
||||
const urlStr = typeof url === 'string' ? url : url.toString()
|
||||
const details: HttpErrorDetails = {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
url: urlStr,
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = resp.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('application/json')) {
|
||||
details.bodyJson = await resp.clone().json()
|
||||
} else {
|
||||
const text = await resp.clone().text()
|
||||
details.bodyIsHtml = contentType.includes('text/html')
|
||||
details.bodySnippet = text.slice(0, 500)
|
||||
}
|
||||
} catch {
|
||||
// Ignore body parsing errors
|
||||
}
|
||||
|
||||
return new HttpError(`Request failed: ${resp.status} ${resp.statusText}`, details)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CSR Client Factory
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a client-side HTTP client for Django.
|
||||
*
|
||||
* @param auth - Authentication strategy (Auth.SESSION or Auth.JWT)
|
||||
* @param config - Client configuration
|
||||
* @returns DjangoHTTPClient
|
||||
*
|
||||
* @example
|
||||
* // Session-based
|
||||
* const client = createDjangoCSRClient(Auth.SESSION)
|
||||
*
|
||||
* @example
|
||||
* // JWT-based
|
||||
* const client = createDjangoCSRClient(Auth.JWT, {
|
||||
* getAccessToken: async () => localStorage.getItem('token')
|
||||
* })
|
||||
*/
|
||||
export function createDjangoCSRClient(auth: Auth.SESSION, config?: CSRClientConfig): DjangoHTTPClient
|
||||
export function createDjangoCSRClient(auth: Auth.JWT, config: JWTClientConfig): DjangoHTTPClient
|
||||
export function createDjangoCSRClient(
|
||||
auth: Auth,
|
||||
config?: CSRClientConfig | JWTClientConfig
|
||||
): DjangoHTTPClient {
|
||||
if (!config?.baseUrl) {
|
||||
throw new Error(
|
||||
'baseUrl is required. Pass it via config or use MizanProvider which provides it automatically.'
|
||||
)
|
||||
}
|
||||
|
||||
const getBaseUrl = () => typeof config.baseUrl === 'function' ? config.baseUrl() : config.baseUrl!
|
||||
|
||||
const getHeaders = async (): Promise<Record<string, string>> => {
|
||||
if (auth === Auth.JWT) {
|
||||
const jwtConfig = config as JWTClientConfig
|
||||
const token = await jwtConfig.getAccessToken()
|
||||
if (token) {
|
||||
return { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
// Session auth uses CSRF
|
||||
return { 'X-CSRFToken': getCSRFToken() ?? '' }
|
||||
}
|
||||
|
||||
function resolveUrl(path: string): string {
|
||||
const base = getBaseUrl()
|
||||
// Absolute base URL — use URL constructor
|
||||
if (base.startsWith('http://') || base.startsWith('https://')) {
|
||||
return new URL(path, base).toString()
|
||||
}
|
||||
// Relative base — path is already usable by fetch in a browser
|
||||
return path
|
||||
}
|
||||
|
||||
return {
|
||||
request: async (method, path, data?, headers?) => {
|
||||
const url = resolveUrl(path)
|
||||
const configHeaders = await getHeaders()
|
||||
const build = buildRequest(method, data, { ...configHeaders, ...headers })
|
||||
return fetch(url, build.request)
|
||||
},
|
||||
|
||||
json: async <T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T> => {
|
||||
const url = resolveUrl(path)
|
||||
const configHeaders = await getHeaders()
|
||||
const build = buildRequest(method, data, { ...configHeaders, ...headers })
|
||||
const resp = await fetch(url, build.request)
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await buildHttpError(resp, url)
|
||||
}
|
||||
|
||||
return resp.json()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Internal Backend URL Resolution
|
||||
// =============================================================================
|
||||
|
||||
function getInternalBackendUrl(override?: string): string {
|
||||
if (override) return override
|
||||
throw new Error(
|
||||
'baseUrl is required for SSR client. Pass it via config.'
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SSR Client Factory
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if cookies is a CookieGetter interface.
|
||||
*/
|
||||
function isCookieGetter(cookies: SSRCookies): cookies is CookieGetter {
|
||||
return typeof (cookies as CookieGetter).get === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract CSRF token and cookie header from SSRCookies.
|
||||
*/
|
||||
function extractCookies(cookies: SSRCookies): { csrf: string; cookieHeader: string } {
|
||||
if (isCookieGetter(cookies)) {
|
||||
return {
|
||||
csrf: cookies.get('csrftoken')?.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 DjangoHTTPClient
|
||||
*
|
||||
* @example
|
||||
* // Next.js server component
|
||||
* import { cookies } from 'next/headers'
|
||||
*
|
||||
* const client = createDjangoSSRClient({ cookies: await cookies() })
|
||||
*/
|
||||
// Re-export auth types for non-React usage
|
||||
export type {
|
||||
BaseUser,
|
||||
AuthDetails,
|
||||
AuthRoutes,
|
||||
JWTTokens,
|
||||
JWTConfig,
|
||||
JWTState,
|
||||
} from './types'
|
||||
|
||||
// Re-export RouterAdapter for libraries that extend it
|
||||
export type { RouterAdapter } from './RouterContext'
|
||||
|
||||
export function createDjangoSSRClient(config: SSRClientConfig): DjangoHTTPClient {
|
||||
const baseUrl = getInternalBackendUrl(config.baseUrl)
|
||||
const { csrf, cookieHeader } = extractCookies(config.cookies)
|
||||
|
||||
return {
|
||||
request: async (method, path, data?, headers?) => {
|
||||
const url = new URL(path, baseUrl)
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRFToken': csrf,
|
||||
'Cookie': cookieHeader,
|
||||
...headers,
|
||||
}
|
||||
|
||||
let body: BodyInit | undefined
|
||||
if (data && !['GET', 'HEAD'].includes(method.toUpperCase())) {
|
||||
if (data instanceof FormData) {
|
||||
body = data
|
||||
} else {
|
||||
body = JSON.stringify(data)
|
||||
requestHeaders['Content-Type'] = 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
method: method.toUpperCase(),
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
},
|
||||
|
||||
json: async <T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T> => {
|
||||
const url = new URL(path, baseUrl)
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRFToken': csrf,
|
||||
'Cookie': cookieHeader,
|
||||
...headers,
|
||||
}
|
||||
|
||||
let body: BodyInit | undefined
|
||||
if (data && !['GET', 'HEAD'].includes(method.toUpperCase())) {
|
||||
if (data instanceof FormData) {
|
||||
body = data
|
||||
} else {
|
||||
body = JSON.stringify(data)
|
||||
requestHeaders['Content-Type'] = 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: method.toUpperCase(),
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
throw await buildHttpError(resp, url)
|
||||
}
|
||||
|
||||
const contentType = resp.headers.get('content-type') ?? ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error(`Expected JSON response but got ${contentType}`)
|
||||
}
|
||||
|
||||
return resp.json()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SSR Session Initialization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Response from the session initialization endpoint.
|
||||
*/
|
||||
interface SessionInitResponse {
|
||||
csrfToken: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a Django session exists before making SSR requests.
|
||||
*
|
||||
* On first visit, the user has no cookies. This function pings Django to
|
||||
* establish a session and get a CSRF token, which can then be used for
|
||||
* subsequent hydration requests in the same SSR request chain.
|
||||
*
|
||||
* Note: Browser cookie forwarding is handled by Next.js middleware, not this
|
||||
* function. This function only ensures cookies exist for SSR data fetching.
|
||||
*
|
||||
* @param config - SSR client configuration with cookies
|
||||
* @returns Object with csrf token and cookie header for use in SSR requests
|
||||
*
|
||||
* @example
|
||||
* // In layout.tsx
|
||||
* const cookieStore = await cookies()
|
||||
* const session = await ensureDjangoSession({ cookies: cookieStore })
|
||||
* const client = createDjangoSSRClient({
|
||||
* cookies: { csrf: session.csrf, cookieHeader: session.cookieHeader }
|
||||
* })
|
||||
*/
|
||||
export async function ensureDjangoSession(config: SSRClientConfig): Promise<{
|
||||
csrf: string
|
||||
cookieHeader: string
|
||||
}> {
|
||||
const baseUrl = getInternalBackendUrl(config.baseUrl)
|
||||
const { csrf: existingCsrf, cookieHeader: existingCookies } = extractCookies(config.cookies)
|
||||
|
||||
// If we already have a CSRF token, just return existing cookies
|
||||
if (existingCsrf) {
|
||||
return { csrf: existingCsrf, cookieHeader: existingCookies }
|
||||
}
|
||||
|
||||
// No CSRF token - need to initialize session
|
||||
const url = new URL('/api/mizan/session/', baseUrl)
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Cookie': existingCookies,
|
||||
},
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
console.error('[mizan] Failed to initialize session:', resp.status, resp.statusText)
|
||||
return { csrf: '', cookieHeader: existingCookies }
|
||||
}
|
||||
|
||||
// Extract CSRF token from response body
|
||||
const data: SessionInitResponse = await resp.json()
|
||||
|
||||
// Extract Set-Cookie headers to build updated cookie string for SSR chain
|
||||
const setCookieHeaders = resp.headers.getSetCookie?.() ?? []
|
||||
const newCookies = setCookieHeaders.map(c => c.split(';')[0]).join('; ')
|
||||
const combinedCookies = existingCookies
|
||||
? `${existingCookies}; ${newCookies}`
|
||||
: newCookies
|
||||
|
||||
return {
|
||||
csrf: data.csrfToken,
|
||||
cookieHeader: combinedCookies,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Server Function HTTP Call
|
||||
// =============================================================================
|
||||
|
||||
// Re-export error types from the canonical location
|
||||
export type { FunctionErrorResponse } from '../errors'
|
||||
import { DjangoError, type FunctionErrorResponse } from '../errors'
|
||||
|
||||
/**
|
||||
* Success response from a server function
|
||||
*/
|
||||
export interface FunctionSuccessResponse<T> {
|
||||
error: false
|
||||
data: T
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for server function responses
|
||||
*/
|
||||
export type FunctionResponse<T> = FunctionSuccessResponse<T> | FunctionErrorResponse
|
||||
|
||||
// Cached CSR client for server function calls
|
||||
let _csrClient: DjangoHTTPClient | null = null
|
||||
|
||||
function getCSRClient(): DjangoHTTPClient {
|
||||
if (!_csrClient) {
|
||||
_csrClient = createDjangoCSRClient(Auth.SESSION)
|
||||
}
|
||||
return _csrClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a Django server function via HTTP.
|
||||
* Used as fallback when WebSocket is unavailable.
|
||||
*
|
||||
* Uses the standard CSR client with session-based auth.
|
||||
*
|
||||
* @param baseUrl - Base URL for the API (e.g., '/api/mizan')
|
||||
* @param functionName - Name of the server function
|
||||
* @param input - Input data for the function
|
||||
* @returns Promise resolving to the function output
|
||||
* @throws FunctionErrorResponse on failure
|
||||
*/
|
||||
export async function httpFunctionCall<TInput = unknown, TOutput = unknown>(
|
||||
baseUrl: string,
|
||||
functionName: string,
|
||||
input?: TInput
|
||||
): Promise<TOutput> {
|
||||
const client = getCSRClient()
|
||||
|
||||
// Use request() not json() because server functions return { error: true/false }
|
||||
// in the body, not HTTP status codes for business errors
|
||||
const response = await client.request(
|
||||
'POST',
|
||||
`${baseUrl}/call/`,
|
||||
{ fn: functionName, args: input }
|
||||
)
|
||||
|
||||
const data: FunctionResponse<TOutput> = await response.json()
|
||||
|
||||
if (data.error) {
|
||||
throw new DjangoError(data)
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user