Restructure tree by role; rename mizan-runtime → mizan-base

packages/ flattens into:
  backends/   server protocol adapters (mizan-django, mizan-ts)
  frontends/  client kernel + framework adapters (mizan-base, mizan-react, mizan-vue, mizan-svelte)
  workers/    runtime workers (mizan-ssr)
  cores/      shared language-level primitives (empty for now; mizan-python forthcoming)

The frontend kernel (was packages/mizan-runtime, now frontends/mizan-base) is
renamed to reflect its role — it's the shared base that frontend adapters
depend on directly. Reflects the substrate position that per-framework adapters
wrap a single shared kernel; codegen targets the adapter, not the raw kernel.

Path updates landed in: Makefile, two Gitea workflows, Dockerfile.test, four
example/harness config files, .claude/settings.local.json, four docs
(CLAUDE/ISSUES/ROADMAP/AFI_ARCHITECTURE), four codegen templates (stage1 +
react/vue/svelte adapters), and three package.jsons (the mizan-base rename
plus mizan-vue/svelte peerDeps).

Generated files under examples/django-react-site/harness/src/api/ still
reference @mizan/runtime — left as-is; they're regenerated artifacts and
the harness is non-functional pending the React wrapper-layer codegen.

Also folded in a pre-existing fix: the Gitea workflows had
working-directory: react / django pointing at a layout that predates
packages/, never updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 20:55:37 -04:00
parent 6eca514777
commit fe39fcb229
126 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,524 @@
/**
* mizan/client
*
* HTTP client factories for Django backends.
* Framework-agnostic - works with vanilla JS, React, Vue, etc.
*
* ## Quick Start
*
* ### Client-Side (CSR)
* ```ts
* import { createMizanCSRClient, Auth } from 'mizan/client'
*
* // Session-based (cookies + CSRF)
* const client = createMizanCSRClient(Auth.SESSION)
*
* // JWT-based (Bearer token)
* const client = createMizanCSRClient(Auth.JWT, { getAccessToken })
*
* const user = await client.json('GET', '/api/accounts/me/')
* ```
*
* ### Server-Side (SSR)
* ```ts
* import { createMizanSSRClient } from 'mizan/client'
*
* const client = createMizanSSRClient({
* cookies: await cookies() // Next.js cookies()
* })
*
* const user = await client.json('GET', '/api/accounts/me/')
* ```
*
* ## React Hooks
*
* For React, import from `/react`:
* ```tsx
* import { useDjangoCSRClient, Auth } from 'mizan/client/react'
*
* const client = useDjangoCSRClient(Auth.SESSION)
* ```
*/
// =============================================================================
// Types
// =============================================================================
/**
* Authentication strategy for client-side requests.
*/
export enum Auth {
/** Session cookies with CSRF token */
SESSION = 'session',
/** JWT Bearer token */
JWT = 'jwt',
}
/**
* Cookie getter interface (matches Next.js cookies() return type).
*/
export interface CookieGetter {
get(name: string): { name: string; value: string } | undefined
getAll(): { name: string; value: string }[]
}
/**
* Cookie configuration for SSR requests.
* Can be either a cookie getter (like Next.js cookies()) or pre-extracted values.
*/
export type SSRCookies = CookieGetter | {
/** CSRF token value */
csrf: string
/** Full cookie header string */
cookieHeader: string
}
/**
* The core HTTP client interface for Django requests.
*/
export interface MizanHTTPClient {
/**
* Make an HTTP request, returning the raw Response.
*/
request(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<Response>
/**
* Make an HTTP request, parsing the response as JSON.
* @throws {HttpError} When response is not ok
*/
json<T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T>
}
/**
* Configuration for CSR client.
*/
export interface CSRClientConfig {
/** Base URL for the Django backend */
baseUrl?: string | (() => string)
}
/**
* Configuration for JWT-authenticated CSR client.
*/
export interface JWTClientConfig extends CSRClientConfig {
/** Async function that returns the current access token */
getAccessToken: () => Promise<string | null>
}
/**
* Configuration for SSR client.
*/
export interface SSRClientConfig {
/** Cookies for authentication forwarding */
cookies: SSRCookies
/** Internal backend URL override (defaults to http://${INTERNAL_BACKEND_HOSTNAME}:8000) */
baseUrl?: string
}
// =============================================================================
// Errors
// =============================================================================
/**
* Details about an HTTP error.
*/
export interface HttpErrorDetails {
status: number
statusText: string
url: string
bodyJson?: unknown
bodySnippet?: string
bodyIsHtml?: boolean
}
/**
* Error thrown when an HTTP request fails.
*/
export class HttpError extends Error {
constructor(
message: string,
public readonly details: HttpErrorDetails
) {
super(message)
this.name = 'HttpError'
}
}
// =============================================================================
// Internal Utilities
// =============================================================================
import { getCSRFToken, getCsrfHeaderName, getCsrfCookieName } from '../utils'
interface RequestBuild {
request: RequestInit
hasBody: boolean
}
function buildRequest(method: string, data?: unknown, headers?: Record<string, string>): RequestBuild {
const isBodyMethod = !['GET', 'HEAD'].includes(method.toUpperCase())
const hasBody = isBodyMethod && data !== undefined
const requestHeaders: Record<string, string> = {
'Accept': 'application/json',
...headers,
}
let body: BodyInit | undefined
if (hasBody) {
if (data instanceof FormData) {
body = data
} else {
body = JSON.stringify(data)
requestHeaders['Content-Type'] = 'application/json'
}
}
return {
request: {
method: method.toUpperCase(),
headers: requestHeaders,
body,
credentials: 'include',
},
hasBody,
}
}
async function buildHttpError(resp: Response, url: URL | string): Promise<HttpError> {
const urlStr = typeof url === 'string' ? url : url.toString()
const details: HttpErrorDetails = {
status: resp.status,
statusText: resp.statusText,
url: urlStr,
}
try {
const contentType = resp.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
details.bodyJson = await resp.clone().json()
} else {
const text = await resp.clone().text()
details.bodyIsHtml = contentType.includes('text/html')
details.bodySnippet = text.slice(0, 500)
}
} catch {
// Ignore body parsing errors
}
return new HttpError(`Request failed: ${resp.status} ${resp.statusText}`, details)
}
// =============================================================================
// CSR Client Factory
// =============================================================================
/**
* Create a client-side HTTP client for Django.
*
* @param auth - Authentication strategy (Auth.SESSION or Auth.JWT)
* @param config - Client configuration
* @returns MizanHTTPClient
*
* @example
* // Session-based
* const client = createMizanCSRClient(Auth.SESSION)
*
* @example
* // JWT-based
* const client = createMizanCSRClient(Auth.JWT, {
* getAccessToken: async () => localStorage.getItem('token')
* })
*/
export function createMizanCSRClient(auth: Auth.SESSION, config?: CSRClientConfig): MizanHTTPClient
export function createMizanCSRClient(auth: Auth.JWT, config: JWTClientConfig): MizanHTTPClient
export function createMizanCSRClient(
auth: Auth,
config?: CSRClientConfig | JWTClientConfig
): MizanHTTPClient {
if (!config?.baseUrl) {
throw new Error(
'baseUrl is required. Pass it via config or use MizanProvider which provides it automatically.'
)
}
const getBaseUrl = () => typeof config.baseUrl === 'function' ? config.baseUrl() : config.baseUrl!
const getHeaders = async (): Promise<Record<string, string>> => {
if (auth === Auth.JWT) {
const jwtConfig = config as JWTClientConfig
const token = await jwtConfig.getAccessToken()
if (token) {
return { Authorization: `Bearer ${token}` }
}
return {}
}
// Session auth uses CSRF
return { [getCsrfHeaderName()]: getCSRFToken() ?? '' }
}
function resolveUrl(path: string): string {
const base = getBaseUrl()
// Absolute base URL — use URL constructor
if (base.startsWith('http://') || base.startsWith('https://')) {
return new URL(path, base).toString()
}
// Relative base — path is already usable by fetch in a browser
return path
}
return {
request: async (method, path, data?, headers?) => {
const url = resolveUrl(path)
const configHeaders = await getHeaders()
const build = buildRequest(method, data, { ...configHeaders, ...headers })
return fetch(url, build.request)
},
json: async <T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T> => {
const url = resolveUrl(path)
const configHeaders = await getHeaders()
const build = buildRequest(method, data, { ...configHeaders, ...headers })
const resp = await fetch(url, build.request)
if (!resp.ok) {
throw await buildHttpError(resp, url)
}
return resp.json()
},
}
}
// =============================================================================
// Internal Backend URL Resolution
// =============================================================================
function getInternalBackendUrl(override?: string): string {
if (override) return override
throw new Error(
'baseUrl is required for SSR client. Pass it via config.'
)
}
// =============================================================================
// SSR Client Factory
// =============================================================================
/**
* Check if cookies is a CookieGetter interface.
*/
function isCookieGetter(cookies: SSRCookies): cookies is CookieGetter {
return typeof (cookies as CookieGetter).get === 'function'
}
/**
* Extract CSRF token and cookie header from SSRCookies.
*/
function extractCookies(cookies: SSRCookies): { csrf: string; cookieHeader: string } {
if (isCookieGetter(cookies)) {
return {
csrf: cookies.get(getCsrfCookieName())?.value ?? '',
cookieHeader: cookies.getAll().map(c => `${c.name}=${c.value}`).join('; ')
}
}
return cookies
}
/**
* Create a server-side HTTP client for Django.
* Used in SSR contexts (Next.js server components, server actions, etc.)
*
* @param config - SSR client configuration with cookies
* @returns MizanHTTPClient
*
* @example
* // Next.js server component
* import { cookies } from 'next/headers'
*
* const client = createMizanSSRClient({ cookies: await cookies() })
*/
// Re-export auth types for non-React usage
export type {
BaseUser,
AuthDetails,
AuthRoutes,
JWTTokens,
JWTConfig,
JWTState,
} from './types'
export function createMizanSSRClient(config: SSRClientConfig): MizanHTTPClient {
const baseUrl = getInternalBackendUrl(config.baseUrl)
const { csrf, cookieHeader } = extractCookies(config.cookies)
return {
request: async (method, path, data?, headers?) => {
const url = new URL(path, baseUrl)
const requestHeaders: Record<string, string> = {
'Accept': 'application/json',
[getCsrfHeaderName()]: csrf,
'Cookie': cookieHeader,
...headers,
}
let body: BodyInit | undefined
if (data && !['GET', 'HEAD'].includes(method.toUpperCase())) {
if (data instanceof FormData) {
body = data
} else {
body = JSON.stringify(data)
requestHeaders['Content-Type'] = 'application/json'
}
}
return fetch(url, {
method: method.toUpperCase(),
headers: requestHeaders,
body,
credentials: 'include',
cache: 'no-store',
})
},
json: async <T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T> => {
const url = new URL(path, baseUrl)
const requestHeaders: Record<string, string> = {
'Accept': 'application/json',
[getCsrfHeaderName()]: csrf,
'Cookie': cookieHeader,
...headers,
}
let body: BodyInit | undefined
if (data && !['GET', 'HEAD'].includes(method.toUpperCase())) {
if (data instanceof FormData) {
body = data
} else {
body = JSON.stringify(data)
requestHeaders['Content-Type'] = 'application/json'
}
}
const resp = await fetch(url, {
method: method.toUpperCase(),
headers: requestHeaders,
body,
credentials: 'include',
cache: 'no-store',
})
if (!resp.ok) {
throw await buildHttpError(resp, url)
}
const contentType = resp.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
throw new Error(`Expected JSON response but got ${contentType}`)
}
return resp.json()
},
}
}
// =============================================================================
// SSR Session Initialization
// =============================================================================
/**
* Response from the session initialization endpoint.
*/
interface SessionInitResponse {
csrfToken: string
}
/**
* Ensure a Django session exists before making SSR requests.
*
* On first visit, the user has no cookies. This function pings Django to
* establish a session and get a CSRF token, which can then be used for
* subsequent hydration requests in the same SSR request chain.
*
* Note: Browser cookie forwarding is handled by Next.js middleware, not this
* function. This function only ensures cookies exist for SSR data fetching.
*
* @param config - SSR client configuration with cookies
* @returns Object with csrf token and cookie header for use in SSR requests
*
* @example
* // In layout.tsx
* const cookieStore = await cookies()
* const session = await ensureMizanSession({ cookies: cookieStore })
* const client = createMizanSSRClient({
* cookies: { csrf: session.csrf, cookieHeader: session.cookieHeader }
* })
*/
export async function ensureMizanSession(config: SSRClientConfig): Promise<{
csrf: string
cookieHeader: string
}> {
const baseUrl = getInternalBackendUrl(config.baseUrl)
const { csrf: existingCsrf, cookieHeader: existingCookies } = extractCookies(config.cookies)
// If we already have a CSRF token, just return existing cookies
if (existingCsrf) {
return { csrf: existingCsrf, cookieHeader: existingCookies }
}
// No CSRF token - need to initialize session
const url = new URL('/api/mizan/session/', baseUrl)
const resp = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Cookie': existingCookies,
},
credentials: 'include',
cache: 'no-store',
})
if (!resp.ok) {
console.error('[mizan] Failed to initialize session:', resp.status, resp.statusText)
return { csrf: '', cookieHeader: existingCookies }
}
// Extract CSRF token from response body
const data: SessionInitResponse = await resp.json()
// Extract Set-Cookie headers to build updated cookie string for SSR chain
const setCookieHeaders = resp.headers.getSetCookie?.() ?? []
const newCookies = setCookieHeaders.map(c => c.split(';')[0]).join('; ')
const combinedCookies = existingCookies
? `${existingCookies}; ${newCookies}`
: newCookies
return {
csrf: data.csrfToken,
cookieHeader: combinedCookies,
}
}
// =============================================================================
// Server Function HTTP Call
// =============================================================================
// Re-export error types from the canonical location
export type { FunctionErrorResponse } from '../errors'
import { MizanError, type FunctionErrorResponse } from '../errors'
/**
* Success response from a server function
*/
export interface FunctionSuccessResponse<T> {
result: T
invalidate?: Array<string | { context: string; params: Record<string, any> }>
}
/**
* Union type for server function responses
*/
export type FunctionResponse<T> = FunctionSuccessResponse<T> | FunctionErrorResponse